@serfab/cadre-core 0.11.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 (121) hide show
  1. package/README.md +3 -2
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +607 -53
  6. package/dist/cadre-node.js +1268 -157
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-database.d.ts +174 -23
  9. package/dist/control-database.js +317 -107
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +103 -0
  12. package/dist/control-read-retry.js +145 -0
  13. package/dist/control-read-retry.js.map +1 -0
  14. package/dist/control-retry.d.ts +101 -0
  15. package/dist/control-retry.js +108 -0
  16. package/dist/control-retry.js.map +1 -0
  17. package/dist/control-schema.d.ts +1 -1
  18. package/dist/control-schema.js +50 -6
  19. package/dist/control-schema.js.map +1 -1
  20. package/dist/control-write-retry.d.ts +59 -35
  21. package/dist/control-write-retry.js +21 -92
  22. package/dist/control-write-retry.js.map +1 -1
  23. package/dist/delegate-admission.d.ts +17 -15
  24. package/dist/delegate-admission.js +17 -15
  25. package/dist/delegate-admission.js.map +1 -1
  26. package/dist/enrolled-machine-store-file.d.ts +13 -0
  27. package/dist/enrolled-machine-store-file.js +41 -0
  28. package/dist/enrolled-machine-store-file.js.map +1 -0
  29. package/dist/enrolled-machine-store.d.ts +81 -0
  30. package/dist/enrolled-machine-store.js +225 -0
  31. package/dist/enrolled-machine-store.js.map +1 -0
  32. package/dist/file-durable-slot.js +5 -3
  33. package/dist/file-durable-slot.js.map +1 -1
  34. package/dist/index.d.ts +10 -6
  35. package/dist/index.js +39 -12
  36. package/dist/index.js.map +1 -1
  37. package/dist/membership-connection-gater.d.ts +226 -26
  38. package/dist/membership-connection-gater.js +316 -30
  39. package/dist/membership-connection-gater.js.map +1 -1
  40. package/dist/node-local-snapshot.d.ts +6 -0
  41. package/dist/node-local-snapshot.js +7 -1
  42. package/dist/node-local-snapshot.js.map +1 -1
  43. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  44. package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
  45. package/dist/peer-join-backfill.js.map +1 -0
  46. package/dist/peer-record.d.ts +26 -0
  47. package/dist/peer-record.js +32 -0
  48. package/dist/peer-record.js.map +1 -1
  49. package/dist/relay-addrs.d.ts +145 -22
  50. package/dist/relay-addrs.js +314 -31
  51. package/dist/relay-addrs.js.map +1 -1
  52. package/dist/relay-reservation.d.ts +20 -15
  53. package/dist/relay-reservation.js +18 -14
  54. package/dist/relay-reservation.js.map +1 -1
  55. package/dist/strand-addr-protocol.js +16 -0
  56. package/dist/strand-addr-protocol.js.map +1 -1
  57. package/dist/strand-database.d.ts +12 -0
  58. package/dist/strand-database.js +16 -0
  59. package/dist/strand-database.js.map +1 -1
  60. package/dist/strand-formation-manager.d.ts +22 -9
  61. package/dist/strand-formation-manager.js +20 -12
  62. package/dist/strand-formation-manager.js.map +1 -1
  63. package/dist/strand-formation-protocol.d.ts +83 -9
  64. package/dist/strand-formation-protocol.js +86 -10
  65. package/dist/strand-formation-protocol.js.map +1 -1
  66. package/dist/strand-instance-manager.d.ts +151 -17
  67. package/dist/strand-instance-manager.js +237 -32
  68. package/dist/strand-instance-manager.js.map +1 -1
  69. package/dist/strand-membership-writer.d.ts +118 -14
  70. package/dist/strand-membership-writer.js +203 -15
  71. package/dist/strand-membership-writer.js.map +1 -1
  72. package/dist/strand-network-config.d.ts +84 -0
  73. package/dist/strand-network-config.js +136 -0
  74. package/dist/strand-network-config.js.map +1 -0
  75. package/dist/strand-revocation-enforcer.d.ts +392 -0
  76. package/dist/strand-revocation-enforcer.js +533 -0
  77. package/dist/strand-revocation-enforcer.js.map +1 -0
  78. package/dist/strand-solicitation.d.ts +10 -3
  79. package/dist/strand-solicitation.js +12 -7
  80. package/dist/strand-solicitation.js.map +1 -1
  81. package/dist/strand-wake-protocol.d.ts +47 -2
  82. package/dist/strand-wake-protocol.js +76 -6
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/strand-watcher.js +6 -1
  85. package/dist/strand-watcher.js.map +1 -1
  86. package/dist/types.d.ts +282 -43
  87. package/dist/types.js +7 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +16 -8
  90. package/src/announce-addrs.ts +93 -0
  91. package/src/cadre-node.ts +1366 -171
  92. package/src/control-database.ts +2628 -2406
  93. package/src/control-read-retry.ts +167 -0
  94. package/src/control-retry.ts +190 -0
  95. package/src/control-schema.ts +50 -6
  96. package/src/control-write-retry.ts +30 -130
  97. package/src/delegate-admission.ts +17 -15
  98. package/src/enrolled-machine-store-file.ts +43 -0
  99. package/src/enrolled-machine-store.ts +280 -0
  100. package/src/file-durable-slot.ts +5 -3
  101. package/src/index.ts +477 -407
  102. package/src/membership-connection-gater.ts +539 -204
  103. package/src/node-local-snapshot.ts +13 -1
  104. package/src/peer-addr-book.ts +251 -250
  105. package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
  106. package/src/peer-record.ts +33 -0
  107. package/src/relay-addrs.ts +436 -111
  108. package/src/relay-reservation.ts +828 -823
  109. package/src/strand-addr-protocol.ts +17 -0
  110. package/src/strand-database.ts +17 -0
  111. package/src/strand-formation-manager.ts +511 -491
  112. package/src/strand-formation-protocol.ts +808 -692
  113. package/src/strand-instance-manager.ts +899 -611
  114. package/src/strand-membership-writer.ts +232 -15
  115. package/src/strand-network-config.ts +172 -0
  116. package/src/strand-revocation-enforcer.ts +653 -0
  117. package/src/strand-solicitation.ts +484 -472
  118. package/src/strand-wake-protocol.ts +94 -8
  119. package/src/strand-watcher.ts +319 -314
  120. package/src/types.ts +1554 -1309
  121. 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,26 +12,30 @@ 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';
18
- import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
19
+ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
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';
30
- import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS } from './membership-connection-gater.js';
32
+ import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
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 { resolveListenAddrs } from './relay-addrs.js';
37
+ import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
38
+ import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
35
39
  import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
36
40
  import { PushFanoutService } from './push-fanout.js';
37
41
  import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
@@ -63,6 +67,30 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
63
67
  function relayStrandAddrPeer(relay) {
64
68
  return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
65
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
+ }
66
94
  /**
67
95
  * Build the signing callback the `ControlDatabase` writers expect: they hand it the
68
96
  * canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
@@ -83,6 +111,56 @@ function requireNonBlank(value, label) {
83
111
  }
84
112
  return trimmed;
85
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
+ }
86
164
  /**
87
165
  * CadreNode is the main entry point for a cadre member.
88
166
  * It manages:
@@ -105,6 +183,14 @@ export class CadreNode {
105
183
  this.trustedOwnerStore = null;
106
184
  this.controlNode = null;
107
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;
108
194
  this.strandWatcher = null;
109
195
  this.seedBootstrapService = null;
110
196
  this.strandSolicitationService = null;
@@ -148,10 +234,11 @@ export class CadreNode {
148
234
  this.windowWaiters = new Set();
149
235
  this.eventHandlers = new Map();
150
236
  /**
151
- * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through.
152
- * Empty (the default) means nobody asked, so {@link getRelayReservationState}
153
- * reports `none` — this whole surface is opt-in and dormant for the CLI/host
154
- * nodes, which use the configured `network.relayAddrs` route instead.
237
+ * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through —
238
+ * or, on a node that names `network.relayAddrs`, the ones
239
+ * {@link driveControlRelayReservation} asked for at the end of {@link start}.
240
+ * Empty (the default) means neither happened, so {@link getRelayReservationState}
241
+ * reports `none`.
155
242
  */
156
243
  this.relayReserveAddrs = [];
157
244
  /**
@@ -166,6 +253,23 @@ export class CadreNode {
166
253
  * supervisor to hold one — i.e. the pre-start `control node unavailable` case.
167
254
  */
168
255
  this.relayReserveError = null;
256
+ /**
257
+ * True from just before the control libp2p node is created until
258
+ * {@link ControlDatabase.initialize} has settled — the connection gate's
259
+ * BRING-UP QUIET PERIOD (`membership-connection-gater.ts`), during which this
260
+ * node refuses every control connection in both directions.
261
+ *
262
+ * The invariant: the control database is built while this node holds zero
263
+ * control connections. Every same-party sibling connected in that window joins
264
+ * the Optimystic cohort the bring-up's block probes consult, and a sibling that
265
+ * has not yet replicated this node's `CadrePeer` row refuses them all — which
266
+ * fails `start()` outright and cannot be retried into convergence (writing the
267
+ * row that would clear the refusal needs the database being built).
268
+ *
269
+ * Cleared on FAILURE as well as success ({@link cleanup} clears it), so
270
+ * teardown is never gated.
271
+ */
272
+ this.controlBringUpInFlight = false;
169
273
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
170
274
  this.sAppConfigs = new Map();
171
275
  /**
@@ -226,10 +330,12 @@ export class CadreNode {
226
330
  /**
227
331
  * In-memory delegate admission grants (see `delegate-admission.ts`): the
228
332
  * strand-node transport peerIds this party's members have announced over the
229
- * strand-addr RPC, admitted at the CONNECTION level so a member's NAT'd
230
- * strand node can hold a circuit-relay reservation on this node. Consulted
231
- * ONLY by {@link admitInboundControlConnection}; the fail-closed per-stream
232
- * gate ({@link authorizeInboundControlStream}) never honors it.
333
+ * strand-addr RPC, admitted at the CONNECTION and RESERVATION levels so a
334
+ * member's NAT'd strand node can hold a circuit-relay reservation on this
335
+ * node (without spending the unauthorized-reservation budget). Consulted
336
+ * ONLY by {@link admitInboundControlConnection} and
337
+ * {@link admitControlRelayReservation}; the fail-closed per-stream gate
338
+ * ({@link authorizeInboundControlStream}) never honors it.
233
339
  */
234
340
  this.delegateAdmission = new DelegateAdmissionStore();
235
341
  /**
@@ -251,6 +357,36 @@ export class CadreNode {
251
357
  * stale stamp.
252
358
  */
253
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();
254
390
  /**
255
391
  * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
256
392
  * this node has applied, keyed by peer id, valued by the seed's multiaddr
@@ -285,6 +421,16 @@ export class CadreNode {
285
421
  * the party if it is ever stranded again.
286
422
  */
287
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;
288
434
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
289
435
  this.selfRegistrationTimer = null;
290
436
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -390,6 +536,7 @@ export class CadreNode {
390
536
  this.config = config;
391
537
  this.strandManager = new StrandInstanceManager();
392
538
  this.enrollmentService = new EnrollmentService();
539
+ this.unauthorizedRelayReservations = new UnauthorizedReservationBudget(config.network?.unauthorizedRelayReservationCap);
393
540
  // Create hibernation manager with callbacks
394
541
  const hibernationCallbacks = {
395
542
  onIdle: async (strandId) => this.handleStrandIdle(strandId),
@@ -501,16 +648,12 @@ export class CadreNode {
501
648
  return;
502
649
  }
503
650
  log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
504
- // NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
505
- // diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
506
- // second such warning ever appears here, route both through a `CadreNodeEvents` entry the
507
- // embedder surfaces instead of growing a console surface inside a library.
508
- if (this.config.network?.announceAddrs && this.config.network.announceAddrs.length > 0) {
509
- console.warn('network.announceAddrs is set but not yet supported (no upstream db-p2p option to apply it) ' +
510
- '— this node will keep advertising its listen/relay addresses instead.');
511
- }
512
651
  try {
513
652
  const tTotal = performance.now();
653
+ // Config pre-flight, inside the guarded region: resolving the listen addrs it reads
654
+ // throws on a malformed `relayAddrs` entry, and that failure belongs on the same
655
+ // logged-and-cleaned-up path as every other one in start().
656
+ this.warnIfAnnounceAddrsDiscardRelay();
514
657
  // Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
515
658
  // wraps globalThis.RTCPeerConnection before the control node can create one.
516
659
  // Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
@@ -529,6 +672,31 @@ export class CadreNode {
529
672
  // closed before any network bring-up, and the retained dial targets are
530
673
  // loaded before the first reconcile pass could consult them.
531
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();
682
+ // Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
683
+ // exists, so no connection can form ahead of it: `@libp2p/bootstrap` emits
684
+ // its discovery events ~1 s after `libp2p.start()` and the connection
685
+ // manager auto-dials from there, which is INSIDE createControlNode below.
686
+ // The invariant being protected is "zero control connections during
687
+ // control-database bring-up" — see the field's doc and
688
+ // `membership-connection-gater.ts` → "The bring-up quiet period".
689
+ //
690
+ // NOTE: without this gate the bootstrap arm is a RACE, not a guarantee — its
691
+ // safety margin is bring-up duration versus @libp2p/bootstrap's 1 s discovery
692
+ // fuse, and bring-up duration is (raw-storage operations) × per-operation
693
+ // latency. The cold-start operation count and the 50-90 ms/op figure a loaded
694
+ // phone actually sees live in `control-database.ts`'s `loadSchema` note (and
695
+ // are pinned by `control-start-storage-op-budget.spec.ts`): at the top of that
696
+ // range bring-up is 9-15 s, far past the fuse. So if this gate is ever removed
697
+ // or bypassed, that arm reopens for exactly the nodes most likely to hit it —
698
+ // phones joining a party, whose membership row has not replicated yet.
699
+ this.controlBringUpInFlight = true;
532
700
  // Create the control network libp2p node
533
701
  let t0 = performance.now();
534
702
  this.controlNode = await this.createControlNode();
@@ -549,6 +717,21 @@ export class CadreNode {
549
717
  });
550
718
  await this.controlDatabase.initialize();
551
719
  timing('[start] controlDatabase.initialize: %dms', Math.round(performance.now() - t0));
720
+ // The database exists locally now, so the quiet period has done its job and
721
+ // this node can be in the conversation. A failure above never reaches here —
722
+ // `cleanup()` on the catch path opens the gate instead, so teardown (and a
723
+ // retry by the embedder) is not gated.
724
+ //
725
+ // NOTE: the window ends HERE, not at the end of start(), so the awaited control-DB
726
+ // work that follows — `strandWatcher.start()`'s first poll — can run against a
727
+ // cohort a connection just joined. Fine now: every block that poll reads was
728
+ // created locally by the initialize() above, so a refusing sibling has nothing to
729
+ // refuse. If a boot ever fails with `BlockUnavailableError` from BELOW this line,
730
+ // that assumption broke. Do not simply move this clear later without measuring:
731
+ // `handleStrandAdded` runs inside `strandWatcher.start()` and seeds each strand
732
+ // node from CONNECTED siblings (`resolveCohortSeed`), so a longer window trades a
733
+ // bring-up hazard for boot-time strand seeds that are always empty.
734
+ this.controlBringUpInFlight = false;
552
735
  log('Control database initialized');
553
736
  // Attach the per-stream gate to the control DB's membership hub, so every
554
737
  // committed `CadrePeer` write re-materializes the authorized snapshot on its
@@ -609,17 +792,38 @@ export class CadreNode {
609
792
  // delayed refresh path) so no early `connection:open` is missed — the
610
793
  // write-while-alone re-replication drain must fire on the first 0→≥1 edge.
611
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();
612
801
  // Seed the per-stream gate's materialized authorized set now that the
613
802
  // control DB is up (non-blocking; the helper never rejects). Until it
614
803
  // lands the snapshot is empty, which the stream gate treats as cold
615
804
  // start and admits.
616
805
  void this.refreshMembershipGate('start');
806
+ // Reserve the configured relays LAST, so every step above ran against a
807
+ // cohort of one (see driveControlRelayReservation). Ahead of
808
+ // scheduleSelfRegistration, so this node's first published `CadrePeer` row
809
+ // already carries the `/p2p-circuit` address the reservation earns it.
810
+ // Throws on a relay that will not have us — start() is fail-fast for a node
811
+ // whose operator named a relay.
812
+ await this.driveControlRelayReservation();
617
813
  // Schedule self-registration in background
618
814
  this.scheduleSelfRegistration();
619
815
  }
620
816
  catch (error) {
621
817
  log('Failed to start CadreNode: %o', error);
818
+ // `driveControlRelayReservation` throws AFTER `control:connected` was emitted,
819
+ // so a subscriber can have seen this node come up. Balance the edge rather than
820
+ // leaving it dangling — `cleanup()` has torn the node down either way, and the
821
+ // events are what an embedder drives its "am I connected" UI from.
822
+ const wasConnected = this._running;
622
823
  await this.cleanup();
824
+ if (wasConnected) {
825
+ this.emit('control:disconnected', undefined);
826
+ }
623
827
  throw error;
624
828
  }
625
829
  }
@@ -657,19 +861,7 @@ export class CadreNode {
657
861
  return;
658
862
  }
659
863
  log('Stopping CadreNode');
660
- // Stop the retry loop BEFORE tearing the node down, not after: `cleanup()`
661
- // stops the control node partway through, and a tick that fires during it
662
- // would dial and then poll a half-torn-down node for the whole reserve
663
- // timeout — logging spurious dial failures and holding the process open.
664
- this.relayReserveSupervisor?.stop();
665
- this.relayReserveSupervisor = null;
666
864
  await this.cleanup();
667
- // Drop the rest of the relay-reservation posture: a stopped node holds no
668
- // reservation, so a restarted instance must not report the previous run's
669
- // `reserved`.
670
- this.relayReserveAddrs = [];
671
- this.relayReserveError = null;
672
- this._running = false;
673
865
  this.emit('control:disconnected', undefined);
674
866
  log('CadreNode stopped');
675
867
  }
@@ -792,6 +984,46 @@ export class CadreNode {
792
984
  }
793
985
  this.bootstrapPeerStore = store;
794
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
+ }
795
1027
  /**
796
1028
  * The node-local cold-start bootstrap-peer store (null before {@link start}) —
797
1029
  * the retained owner addresses {@link reconcileControlCohort}'s cold-start
@@ -838,6 +1070,90 @@ export class CadreNode {
838
1070
  async createControlNode() {
839
1071
  return await createLibp2pNode(this.buildControlNodeOptions());
840
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
+ }
841
1157
  /**
842
1158
  * The control database's cache-wrapped raw storage, resolved from
843
1159
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -868,6 +1184,42 @@ export class CadreNode {
868
1184
  this.controlStorage = wrapStorageWithCache(resolved, 'control');
869
1185
  return this.controlStorage;
870
1186
  }
1187
+ /**
1188
+ * Warn the operator when `network.announceAddrs` will silently cost this node the
1189
+ * relay reachability it also configured.
1190
+ *
1191
+ * A non-empty announce set REPLACES everything libp2p advertises, so the
1192
+ * `/p2p-circuit` address earned by a relay reservation is dropped from the node's
1193
+ * advertised addresses even though the reservation itself is still held — peers
1194
+ * behind NAT stop being able to reach it through that relay. Not an error: an
1195
+ * operator whose relay slot is decorative may genuinely want only the announced
1196
+ * address, so this reports and proceeds.
1197
+ *
1198
+ * Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
1199
+ * hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
1200
+ * longer route — is caught too.
1201
+ *
1202
+ * NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
1203
+ * diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
1204
+ * second such warning ever appears here, route both through a `CadreNodeEvents` entry the
1205
+ * embedder surfaces instead of growing a console surface inside a library.
1206
+ */
1207
+ warnIfAnnounceAddrsDiscardRelay() {
1208
+ const network = this.config.network;
1209
+ if (!replacesAdvertisedAddrs(network)) {
1210
+ return;
1211
+ }
1212
+ const listensOnCircuit = (resolveListenAddrs(network) ?? [])
1213
+ .some((addr) => addr.includes('/p2p-circuit'));
1214
+ if (!listensOnCircuit) {
1215
+ return;
1216
+ }
1217
+ console.warn('network.announceAddrs is set alongside a circuit-relay listener (from network.relayAddrs, ' +
1218
+ 'or a /p2p-circuit entry in network.listenAddrs). A non-empty announce set REPLACES every ' +
1219
+ 'address this node advertises, so the /p2p-circuit address earned from the relay reservation ' +
1220
+ 'will not be advertised and peers will stop reaching this node through that relay. ' +
1221
+ 'Use network.appendAnnounceAddrs instead to advertise an extra address without discarding the rest.');
1222
+ }
871
1223
  /**
872
1224
  * Map this node's config onto the control network's libp2p node options.
873
1225
  *
@@ -879,17 +1231,24 @@ export class CadreNode {
879
1231
  buildControlNodeOptions() {
880
1232
  const { controlNetwork, network, profile } = this.config;
881
1233
  const identityKey = this.identityKey;
882
- const listenAddrs = resolveListenAddrs(network);
883
- // Determine relay mode: if explicitly set in config, use that;
884
- // otherwise default to true for storage profile nodes (better connectivity/uptime)
885
- const enableRelay = network?.enableRelay ?? (profile === 'storage');
1234
+ // `'search'`: `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH
1235
+ // entry, which opens no connection — the reservation is driven explicitly at
1236
+ // the END of `start()`, so the control database is built while this node holds
1237
+ // zero control connections. See `relay-addrs.ts` and `start()`.
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);
1244
+ const enableRelay = this.relayServerEnabled();
886
1245
  // The control node's own storage, resolved once per runtime (see
887
1246
  // {@link resolveControlStorage}) rather than per call.
888
1247
  const controlStorageProvider = this.resolveControlStorage();
889
1248
  const nodeOptions = {
890
1249
  port: 0,
891
1250
  bootstrapNodes: controlNetwork.bootstrapNodes,
892
- networkName: `control-${controlNetwork.partyId}`,
1251
+ networkName: this.controlNetworkName(),
893
1252
  storage: controlStorageProvider,
894
1253
  fretProfile: profile === 'storage' ? 'core' : 'edge',
895
1254
  relay: enableRelay,
@@ -900,19 +1259,41 @@ export class CadreNode {
900
1259
  // CONTROL_CLUSTER_POLICY: only the admission gate defaults to 2, while the read-repair
901
1260
  // corroboration floor would otherwise fall back to this clusterSize of 16.
902
1261
  clusterSize: CONTROL_REPLICATION_BREADTH,
903
- 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),
904
1271
  arachnode: { enableRingZulu: profile === 'storage' },
905
1272
  ...(identityKey && { privateKey: identityKey }),
906
1273
  ...(network?.transports && { transports: network.transports }),
907
- // Configured `listenAddrs`, plus a `/p2p-circuit` listener for every
908
- // `network.relayAddrs` entry — that listener is what reserves the relay slot.
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,
1278
+ // Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
1279
+ // `network.relayAddrs` is set — that listener registers the pending reservation
1280
+ // `driveControlRelayReservation` fills after bring-up.
909
1281
  ...(listenAddrs && { listenAddrs }),
1282
+ // What this node ADVERTISES, which is not the same question as what it binds.
1283
+ // Either field is present only when configured non-empty; a non-empty
1284
+ // `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
1285
+ ...resolveAnnounceAddrs(network),
910
1286
  // The CONTROL node composes the membership admission gate onto any
911
- // caller-supplied gater (deny from either wins on inbound; all other
912
- // hooks pass through). Strand cohort nodes keep the raw configured gater
913
- // (see strand-instance-manager.ts) — their peers are legitimately
1287
+ // caller-supplied gater (deny from either wins on the inbound-encrypted
1288
+ // and relay-reservation hooks; all other hooks pass through). Strand
1289
+ // cohort nodes keep the raw configured gater (see
1290
+ // strand-instance-manager.ts) — their peers are legitimately
914
1291
  // cross-party, so cadre membership must not gate them.
915
- connectionGater: createMembershipConnectionGater({ admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId) }, network?.connectionGater),
1292
+ connectionGater: createMembershipConnectionGater({
1293
+ admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId),
1294
+ admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
1295
+ bringUpInFlight: () => this.controlBringUpInFlight
1296
+ }, network?.connectionGater),
916
1297
  // Fail-closed per-stream authorization for the four Optimystic control-DB
917
1298
  // protocols — the members-only layer the connection gater's stranger
918
1299
  // carve-outs cannot express (see authorizeInboundControlStream). Control
@@ -921,6 +1302,17 @@ export class CadreNode {
921
1302
  };
922
1303
  return nodeOptions;
923
1304
  }
1305
+ /**
1306
+ * Does this node's CONTROL libp2p run the circuit-relay server? An explicit
1307
+ * `network.enableRelay` wins; otherwise storage-profile nodes default to on
1308
+ * (better connectivity/uptime). Shared by {@link buildControlNodeOptions}
1309
+ * (which configures the server from it) and
1310
+ * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch
1311
+ * must agree with whether a reservation is even servable here).
1312
+ */
1313
+ relayServerEnabled() {
1314
+ return this.config.network?.enableRelay ?? (this.config.profile === 'storage');
1315
+ }
924
1316
  /**
925
1317
  * Decide whether an inbound CONTROL-network connection from `remotePeerId`
926
1318
  * should be admitted — the policy behind the connection gater wired in
@@ -930,7 +1322,7 @@ export class CadreNode {
930
1322
  * `membership-connection-gater.ts` for the layer's rationale and the
931
1323
  * stranger-open protocol allowlist).
932
1324
  *
933
- * Admits when ANY of:
1325
+ * Returns `'admit'` when ANY of:
934
1326
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
935
1327
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
936
1328
  * configured bootstrap/relay infrastructure);
@@ -967,44 +1359,117 @@ export class CadreNode {
967
1359
  * this node yet is denied even though the formation handler would have
968
1360
  * accepted it, exactly like the unreplicated-membership-row case below.
969
1361
  *
1362
+ * When every check falls through, the verdict depends on whether this node
1363
+ * runs the circuit-relay server ({@link relayServerEnabled}): without one,
1364
+ * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
1365
+ * established by the reserving peer DIALING the relay, so a connection deny
1366
+ * here kills the reservation, and that deny is NOT self-healing: an outbound
1367
+ * reconcile re-dial re-establishes a data link, but no outbound dial can
1368
+ * grant the remote peer a reservation, and a relay-only peer has no address
1369
+ * of its own to dial back — the reservation IS its address. The gater admits
1370
+ * such a connection, decides the reservation via
1371
+ * {@link admitControlRelayReservation}, and drops the connection if no
1372
+ * reservation is admitted in time (see `membership-connection-gater.ts` →
1373
+ * "The relay-reservation seam").
1374
+ *
970
1375
  * NOTE: check 4/5 runs a control-DB read per inbound connection
971
1376
  * (`listAuthorizedMembers`); connections are rare and cadres small, and this
972
1377
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
973
1378
  * read is safe here — unlike the per-stream gate, which must consult the
974
1379
  * materialized {@link authorizedControlPeers} snapshot instead.
975
- * NOTE: a sibling whose membership row has not yet replicated to
976
- * this node is denied until the row converges (typically via the owner);
977
- * either side's next outbound reconcile dial (outbound is never gated)
978
- * re-establishes the link — self-healing, but visible as a transient deny.
1380
+ * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
1381
+ * replicated here is denied until the row converges (typically via the
1382
+ * owner); either side's next outbound reconcile dial (outbound is never
1383
+ * gated) re-establishes the DATA link — self-healing for data, visible as a
1384
+ * transient deny. That self-healing story never covered a reservation, which
1385
+ * is exactly why the relay-enabled path above exists.
979
1386
  */
980
1387
  async admitInboundControlConnection(remotePeerId) {
981
1388
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
982
- return true;
1389
+ return 'admit';
983
1390
  }
984
1391
  if (Date.now() < this.enrollmentWindowUntil) {
985
- return true;
1392
+ return 'admit';
986
1393
  }
987
1394
  if (this.delegateAdmission.has(remotePeerId)) {
988
- return true;
1395
+ return 'admit';
989
1396
  }
990
1397
  const authorized = await this.listAuthorizedMembers();
991
1398
  if (authorized.length === 0) {
992
- return true;
1399
+ return 'admit';
993
1400
  }
994
1401
  if (authorized.some((m) => m.peerId === remotePeerId)) {
995
- return true;
1402
+ return 'admit';
996
1403
  }
997
1404
  try {
998
1405
  if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
999
- return true;
1406
+ return 'admit';
1000
1407
  }
1001
1408
  }
1002
1409
  catch (error) {
1003
1410
  log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
1004
- return true;
1411
+ return 'admit';
1412
+ }
1413
+ if (this.relayServerEnabled()) {
1414
+ log('admitInboundControlConnection: admitting %s FOR RELAY ONLY — not an authorized member and no enrollment path open; the gater drops the connection unless a reservation is admitted', remotePeerId);
1415
+ return 'admit-for-relay';
1005
1416
  }
1006
1417
  log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
1007
- return false;
1418
+ return 'deny';
1419
+ }
1420
+ /**
1421
+ * Should `remotePeerId` be granted a circuit-relay reservation slot on this
1422
+ * node's relay server? The policy behind the gater's
1423
+ * `denyInboundRelayReservation` hook (see `membership-connection-gater.ts` →
1424
+ * "The relay-reservation seam"); the circuit-relay server consults it per
1425
+ * RESERVE request, so it is never called on a node whose relay server is off.
1426
+ *
1427
+ * Admits when ANY of:
1428
+ * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
1429
+ * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
1430
+ * configured bootstrap/relay infrastructure);
1431
+ * 2. the peer holds a live delegate admission grant — a member's strand
1432
+ * node reserving here (see `delegate-admission.ts`); never counted
1433
+ * against the unauthorized budget;
1434
+ * 3. the authorized-member set is empty (cold start) or the peer IS an
1435
+ * authorized member — members are never counted against the budget; or
1436
+ * 4. the peer takes (or already holds) a slot in the bounded
1437
+ * unauthorized-reservation budget — the boot-ordering window where a
1438
+ * genuine member reserves before its `CadrePeer` row replicates here.
1439
+ * Cap via `network.unauthorizedRelayReservationCap` (0 = refuse every
1440
+ * unauthorized reservation).
1441
+ *
1442
+ * Every uncounted admission also RELEASES the budget slot the peer may hold
1443
+ * from an earlier reservation taken while it was still unplaceable — a member
1444
+ * that boots, reserves, and only then has its row replicate here would
1445
+ * otherwise keep that slot spent for the rest of the entry's TTL.
1446
+ *
1447
+ * The connection gate's stranger carve-outs (an open enrollment window, an
1448
+ * outstanding formation invitation) deliberately do NOT extend here: they
1449
+ * exist so a stranger's SEED or FORMATION stream can ride a connection, and
1450
+ * neither needs relay capacity. A genuine invitee that does need a relay slot
1451
+ * takes one from the budget like any other unplaced peer, so an open window
1452
+ * never becomes an unbounded grant of this node's forwarding capacity.
1453
+ */
1454
+ async admitControlRelayReservation(remotePeerId) {
1455
+ if (this.admitControlPeerUnconditionally(remotePeerId)) {
1456
+ return this.admitReservationUncounted(remotePeerId);
1457
+ }
1458
+ if (this.delegateAdmission.has(remotePeerId)) {
1459
+ return this.admitReservationUncounted(remotePeerId);
1460
+ }
1461
+ const authorized = await this.listAuthorizedMembers();
1462
+ if (authorized.length === 0 || authorized.some((m) => m.peerId === remotePeerId)) {
1463
+ return this.admitReservationUncounted(remotePeerId);
1464
+ }
1465
+ const admitted = this.unauthorizedRelayReservations.tryAdmit(remotePeerId);
1466
+ log('admitControlRelayReservation: %s %s under the unauthorized-reservation budget', admitted ? 'admitting' : 'REFUSING', remotePeerId);
1467
+ return admitted;
1468
+ }
1469
+ /** Admit a reservation on the peer's own merits, giving back any budget slot it still holds. */
1470
+ admitReservationUncounted(remotePeerId) {
1471
+ this.unauthorizedRelayReservations.release(remotePeerId);
1472
+ return true;
1008
1473
  }
1009
1474
  /**
1010
1475
  * Hold the control-network inbound gate open for not-yet-authorized peers
@@ -1020,7 +1485,8 @@ export class CadreNode {
1020
1485
  /**
1021
1486
  * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
1022
1487
  * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
1023
- * at the CONNECTION level (all a circuit-relay reservation needs) for
1488
+ * at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
1489
+ * needs, without spending the unauthorized-reservation budget) for
1024
1490
  * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
1025
1491
  * authorized-membership gate has passed; public so tests can drive the
1026
1492
  * admission policy without a full strand launch. A re-announce for the same
@@ -1123,15 +1589,83 @@ export class CadreNode {
1123
1589
  *
1124
1590
  * The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
1125
1591
  * so two reads can no longer settle out of order.
1592
+ *
1593
+ * Reads WITHOUT the transient-failure retry, deliberately: `ControlDatabase` drives
1594
+ * this listener with its write lock HELD (`notifyMembershipChanged`), so a retrying
1595
+ * read here would sleep its backoff holding the lock and stall every other local
1596
+ * writer. Nothing is lost — this refresh already keeps the previous snapshot on
1597
+ * failure and is re-driven by the next membership write and by the timed reconcile.
1126
1598
  */
1127
1599
  async refreshAuthorizedControlPeers(reason) {
1128
1600
  if (!this._running || !this.controlDatabase) {
1129
1601
  return;
1130
1602
  }
1131
1603
  try {
1132
- const members = await this.listAuthorizedMembers();
1604
+ const members = await this.listAuthorizedMembers(false);
1133
1605
  this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
1134
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();
1135
1669
  }
1136
1670
  catch (error) {
1137
1671
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
@@ -1351,6 +1885,29 @@ export class CadreNode {
1351
1885
  }
1352
1886
  return null;
1353
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
+ }
1354
1911
  /**
1355
1912
  * The owner keypair (base64url Ed25519) derived from this node's resolved
1356
1913
  * identity key. In the single-key reference model the owner signing key is
@@ -1411,6 +1968,20 @@ export class CadreNode {
1411
1968
  reconcile('self:peer:update');
1412
1969
  };
1413
1970
  this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
1971
+ // NOTE: a node AUTHORIZED AFTER IT BOOTED can wait a whole heartbeat (7.5 min)
1972
+ // to publish its address. `registerSelf` no-ops while a node has no readable
1973
+ // `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
1974
+ // wired here are the only ones after that: `self:peer:update` needs an ADDRESS
1975
+ // CHANGE, and a relay-only node's addresses stop changing the moment its
1976
+ // reservation lands — inside `start()`, before this listener exists. Harmless
1977
+ // where a late-authorized node's addresses keep churning (NAT/relay rotation
1978
+ // fire the event anyway), and invisible while parties authorize members before
1979
+ // they boot. If late enrollment becomes the normal path — an invited phone is
1980
+ // exactly that — republish on the membership-change seam that already exists
1981
+ // (`ControlDatabase.setMembershipChangeListener`) rather than lengthening this
1982
+ // list of triggers. Measured while writing
1983
+ // `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
1984
+ // explicitly because neither trigger is dependable there.
1414
1985
  this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
1415
1986
  this.recordRefreshTimer?.unref?.();
1416
1987
  const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
@@ -1485,6 +2056,10 @@ export class CadreNode {
1485
2056
  * Survivors are returned signaling (`/p2p-circuit`) first, filtered to
1486
2057
  * signaling-only when requested, as parsed `Multiaddr`s (unparsable addrs
1487
2058
  * dropped). Any gate failure yields an empty array rather than throwing.
2059
+ *
2060
+ * Every returned address is normalized to terminate in `/p2p/<peerId>` — see
2061
+ * {@link normalizeDialAddrs} for why that invariant, not each dial site's own
2062
+ * handling, is what keeps a mixed list from taking a whole peer offline.
1488
2063
  */
1489
2064
  async resolvePeerAddrs(peerId, opts = {}) {
1490
2065
  if (!this.controlDatabase) {
@@ -1529,12 +2104,76 @@ export class CadreNode {
1529
2104
  }
1530
2105
  // Order signaling-first (the on-record order was what we verified above),
1531
2106
  // optionally restrict to signaling addrs, then parse — dropping any addr
1532
- // that does not parse as a multiaddr.
2107
+ // that does not parse as a multiaddr — and finally normalize every survivor
2108
+ // onto `/p2p/<peerId>` so no caller ever sees a mixed list.
1533
2109
  let addrs = orderSignalingFirst(record.addrs);
1534
2110
  if (opts.signalingOnly) {
1535
2111
  addrs = addrs.filter(isSignalingAddr);
1536
2112
  }
1537
- return this.parseMultiaddrs(addrs);
2113
+ return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
2114
+ }
2115
+ /**
2116
+ * Guarantee every address handed to a control-network dial terminates in
2117
+ * `/p2p/<peerId>`, with no duplicates — the invariant every dial path
2118
+ * downstream relies on.
2119
+ *
2120
+ * `libp2p.dial(addrs)` requires that the addresses in ONE dial either all name
2121
+ * a peer id or none do; a list mixing a circuit address (which ends in
2122
+ * `/p2p/<target>`) with a bare direct one (which ends in `/tcp/…`) makes that
2123
+ * call throw, and the whole peer is then silently skipped rather than one bad
2124
+ * address.
2125
+ *
2126
+ * Applied by ALL THREE sources of control-dial candidates, so no call site
2127
+ * invents its own rule and none can produce a mixed list: the signed record
2128
+ * ({@link resolvePeerAddrs}), the libp2p address book
2129
+ * ({@link peerStoreAddrs}), and a seed's retained owner addresses
2130
+ * ({@link bootstrapDialAddrs}).
2131
+ *
2132
+ * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
2133
+ * `peerId`, so it does not belong in this peer's candidate list (libp2p's own
2134
+ * dial queue filters the same shape out one layer lower).
2135
+ */
2136
+ normalizeDialAddrs(addrs, peerId) {
2137
+ // Keyed by the NORMALIZED string, so two entries differing only by the suffix
2138
+ // (`/ip4/…/tcp/4001` and `/ip4/…/tcp/4001/p2p/<peerId>`) collapse into one.
2139
+ // Normalization is what makes them equal; a surviving duplicate would cost a
2140
+ // real dial attempt — a whole slice of `dialWake`'s budget — on an address
2141
+ // already being tried.
2142
+ const out = new Map();
2143
+ for (const addr of addrs) {
2144
+ const bound = this.bindAddrToPeer(addr, peerId);
2145
+ if (bound) {
2146
+ out.set(bound.toString(), bound);
2147
+ }
2148
+ }
2149
+ return [...out.values()];
2150
+ }
2151
+ /**
2152
+ * One address bound to `peerId`, or `null` when it cannot be — logged either
2153
+ * way, never thrown, so every caller's list-shaping stays total.
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
+ *
2160
+ * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
2161
+ * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
2162
+ * above parsed it already) and for our own node's id, reachable for a
2163
+ * seed-supplied one ({@link bootstrapDialAddrs}).
2164
+ */
2165
+ bindAddrToPeer(addr, peerId) {
2166
+ try {
2167
+ const bound = withTrailingPeerId(addr, peerId);
2168
+ if (!bound) {
2169
+ log('bindAddrToPeer: dropping addr %s — it names a peer other than %s', addr.toString(), peerId);
2170
+ }
2171
+ return bound;
2172
+ }
2173
+ catch (error) {
2174
+ log('bindAddrToPeer: cannot bind %s to %s: %o', addr.toString(), peerId, error);
2175
+ return null;
2176
+ }
1538
2177
  }
1539
2178
  /** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
1540
2179
  parseMultiaddrs(addrs) {
@@ -1851,6 +2490,16 @@ export class CadreNode {
1851
2490
  * Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
1852
2491
  * {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
1853
2492
  * parse/lookup failure — never throws.
2493
+ *
2494
+ * Normalized through {@link normalizeDialAddrs} for the same reason
2495
+ * {@link resolvePeerAddrs} is, and with more cause: the address book hands back
2496
+ * a list that is inherently MIXED. `@libp2p/peer-store`'s
2497
+ * `dedupeFilterAndSortAddresses` strips a trailing `/p2p/<peerId>` only when
2498
+ * that id is the address's FIRST `/p2p/` component, so a direct address
2499
+ * round-trips bare while a relayed one — whose first `/p2p/` names the relay —
2500
+ * keeps its suffix (the same asymmetry `peer-addr-book.ts`'s `addrKey` exists
2501
+ * to absorb). Feeding both to one `dial()` is exactly the
2502
+ * `InvalidParametersError` that skipped the whole peer.
1854
2503
  */
1855
2504
  async peerStoreAddrs(peerId) {
1856
2505
  if (!this.controlNode) {
@@ -1860,7 +2509,7 @@ export class CadreNode {
1860
2509
  const peer = await this.controlNode.peerStore.get(peerIdFromString(peerId));
1861
2510
  // Re-parse through the top-level multiaddr parser so the returned type matches
1862
2511
  // resolvePeerAddrs (the peerStore bundles its own @multiformats/multiaddr copy).
1863
- return this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString()));
2512
+ return this.normalizeDialAddrs(this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString())), peerId);
1864
2513
  }
1865
2514
  catch (error) {
1866
2515
  log('reconcileControlCohort: peerStore lookup for %s failed: %o', peerId, error);
@@ -1949,6 +2598,13 @@ export class CadreNode {
1949
2598
  if (!this._running || !this.controlNode) {
1950
2599
  return;
1951
2600
  }
2601
+ // NOTE: a connection this node still holds in `status: 'open'` after the remote
2602
+ // has already aborted it counts as connected here, so the cold-start retry stays
2603
+ // suppressed for that peer until the connection monitor's next ping notices the
2604
+ // abort — measured at ~9 s in `control-cohort-cold-start-retry.integration.ts`.
2605
+ // Bounded and self-healing today; it would start to matter if the connection
2606
+ // monitor's ping interval were lengthened, or if recovery ever had to meet a
2607
+ // deadline tighter than one ping interval.
1952
2608
  if (connected.has(peerId)) {
1953
2609
  continue;
1954
2610
  }
@@ -1962,32 +2618,15 @@ export class CadreNode {
1962
2618
  * Bind a bootstrap peer's seed addresses to its peer id, so the dial
1963
2619
  * authenticates the peer it is aiming at rather than trusting whoever answers.
1964
2620
  *
1965
- * An address that already carries `/p2p/<id>` must carry THIS peer's id or it
1966
- * is dropped (a seed that disagrees with itself is not a dial target); one
1967
- * that carries none — an `addressOverrides`-published record, say — gets the
1968
- * id encapsulated. Unencapsulatable addresses are dropped rather than dialed
1969
- * bare.
2621
+ * The same {@link normalizeDialAddrs} rule the resolved and address-book paths
2622
+ * use, on the third and last source of control-dial candidates: an address that
2623
+ * already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
2624
+ * (a seed that disagrees with itself is not a dial target); one that does not —
2625
+ * a bare listen addr, or a relay hop with the destination missing — gets the id
2626
+ * encapsulated; an unencapsulatable one is dropped rather than dialed bare.
1970
2627
  */
1971
2628
  bootstrapDialAddrs(peerId, addrs) {
1972
- const out = [];
1973
- for (const addr of this.parseMultiaddrs(addrs)) {
1974
- const embedded = trailingPeerId(addr);
1975
- if (embedded === peerId) {
1976
- out.push(addr);
1977
- continue;
1978
- }
1979
- if (embedded !== null) {
1980
- log('reconcileControlCohort(cold-start): addr %s names peer %s, not %s; dropping', addr.toString(), embedded, peerId);
1981
- continue;
1982
- }
1983
- try {
1984
- out.push(addr.encapsulate(`/p2p/${peerId}`));
1985
- }
1986
- catch (error) {
1987
- log('reconcileControlCohort(cold-start): cannot bind %s to %s: %o', addr.toString(), peerId, error);
1988
- }
1989
- }
1990
- return out;
2629
+ return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
1991
2630
  }
1992
2631
  /** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
1993
2632
  async dialBootstrapPeer(peerId, addrs) {
@@ -2705,7 +3344,30 @@ export class CadreNode {
2705
3344
  });
2706
3345
  }
2707
3346
  }
3347
+ /**
3348
+ * Return this node to its pre-{@link start} state. The ONE teardown — `stop()` and
3349
+ * a failed `start()` both come here, so anything a stopped node must not still be
3350
+ * doing belongs in this method and not in `stop()`. `stop()` adds only the
3351
+ * `control:disconnected` emit, which a never-connected start has no edge for.
3352
+ */
2708
3353
  async cleanup() {
3354
+ // Open the connection gate's bring-up quiet period unconditionally: teardown
3355
+ // must never be gated, and this is the path a FAILED start() takes.
3356
+ this.controlBringUpInFlight = false;
3357
+ // Stop the relay retry loop BEFORE tearing the node down, not after: the control
3358
+ // node is stopped partway through below, and a tick that fires during it would
3359
+ // dial and then poll a half-torn-down node for the whole reserve timeout —
3360
+ // logging spurious dial failures on a `.unref()`'d timer that nothing ever
3361
+ // clears. `driveControlRelayReservation` starts this supervisor from inside
3362
+ // `start()`, so a start that fails ON the reservation leaves one running unless
3363
+ // it is stopped here.
3364
+ this.relayReserveSupervisor?.stop();
3365
+ this.relayReserveSupervisor = null;
3366
+ // Drop the rest of the relay-reservation posture: a torn-down node holds no
3367
+ // reservation, so neither a restarted instance nor a caller inspecting a failed
3368
+ // start reports the previous attempt's `reserved`.
3369
+ this.relayReserveAddrs = [];
3370
+ this.relayReserveError = null;
2709
3371
  // Resolve + clear any in-flight wake windows first, so an in-flight check-in
2710
3372
  // or serviceWake unblocks and tears down cleanly rather than firing a stale
2711
3373
  // window timer (or hanging) after the strand manager is stopped below.
@@ -2759,6 +3421,14 @@ export class CadreNode {
2759
3421
  // announce timestamps that would suppress the next session's refresh.
2760
3422
  this.delegateAdmission.clear();
2761
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
+ }
2762
3432
  // Close control database (this also shuts down the collection factory).
2763
3433
  // Detach the membership hub first: nothing this teardown does should drive a
2764
3434
  // gate refresh, and a notification arriving after this point is a no-op.
@@ -2773,16 +3443,23 @@ export class CadreNode {
2773
3443
  await this.controlNode.stop();
2774
3444
  this.controlNode = null;
2775
3445
  }
2776
- // Release the control store's cache registration LAST — after the database and
2777
- // node that write through it are down. Dropping the field is what lets a
2778
- // stop()/start() cycle re-resolve the provider against a live cache instead of
2779
- // handing the restarted node a retired wrapper. Logged, never thrown: a
2780
- // cache-bookkeeping failure must not abort a teardown.
3446
+ // Release the control store's claim on its cache LAST — after the database and
3447
+ // node that write through it are down. The wrapper counts holders, so this empties
3448
+ // and unregisters the cache only if no other scope still holds it. Dropping the
3449
+ // field is what lets a stop()/start() cycle re-resolve the provider against a live
3450
+ // cache instead of handing the restarted node a retired wrapper. Logged, never
3451
+ // thrown: a cache-bookkeeping failure must not abort a teardown.
2781
3452
  if (this.controlStorage) {
2782
3453
  const controlStorage = this.controlStorage;
2783
3454
  this.controlStorage = null;
2784
3455
  await disposeStorageCache(controlStorage).catch((err) => log('Failed to dispose control storage cache: %o', err));
2785
3456
  }
3457
+ // LAST, so every step above still ran against a node that considered itself up
3458
+ // (several of them early-return while `_running` is false). A failed `start()`
3459
+ // that had already flipped it — the reservation drive throws after that point —
3460
+ // must not leave `isRunning` true over a torn-down node, which would report
3461
+ // healthy and make the embedder's retry a no-op ("already running").
3462
+ this._running = false;
2786
3463
  }
2787
3464
  // Hibernation callbacks
2788
3465
  async handleStrandIdle(strandId) {
@@ -2852,8 +3529,8 @@ export class CadreNode {
2852
3529
  /**
2853
3530
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
2854
3531
  * first: the discovery seed may have grown since the strand last ran. Shared by
2855
- * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
2856
- * 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
2857
3534
  * (returns the live instance unchanged) as a backstop against double-resume.
2858
3535
  */
2859
3536
  async resumeStrandRuntime(strandId) {
@@ -2864,6 +3541,9 @@ export class CadreNode {
2864
3541
  ? peerIdFromPrivateKey(await strandTransportKey(this.identityKey, strandId)).toString()
2865
3542
  : undefined;
2866
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.
2867
3547
  const instance = await this.strandManager.resumeStrand(strandId, { bootstrapNodes });
2868
3548
  // Same reason as the launch path: `bootstrapNodes` only reaches the address
2869
3549
  // book through @libp2p/bootstrap discovery, so merge it directly as well.
@@ -2997,6 +3677,14 @@ export class CadreNode {
2997
3677
  * Add a strand with its sApp configuration.
2998
3678
  * The hosting application must provide the sApp schema when creating a strand.
2999
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
+ *
3000
3688
  * A rejected call leaves nothing running but DOES leave the sApp config
3001
3689
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
3002
3690
  * automatic relaunch need it. So once the strand's row is visible on the control
@@ -3012,9 +3700,11 @@ export class CadreNode {
3012
3700
  const { strandRow, sAppConfig, founder } = config;
3013
3701
  // Store sApp config for this strand
3014
3702
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
3015
- log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? false);
3016
- // `founder` only flows from the explicit addStrand path — the control-discovered
3017
- // 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.
3018
3708
  return await this.launchStrand(strandRow, sAppConfig, founder);
3019
3709
  }
3020
3710
  /**
@@ -3022,16 +3712,41 @@ export class CadreNode {
3022
3712
  * owner identity, so other cadre members discover it via control-network
3023
3713
  * sync (their {@link StrandWatcher} fires `strand:discovered`).
3024
3714
  *
3025
- * This is the owner-signed `Strand` INSERT that {@link addStrand}
3026
- * deliberately omits: `addStrand` only starts the LOCAL strand instance,
3027
- * whereas publishing makes the strand visible cadre-wide. A typical creator
3028
- * does both (start locally + publish); a discovering peer only does
3029
- * `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.
3030
3743
  *
3031
3744
  * The insert is signed with the ed25519 key behind this node's PeerId — which
3032
3745
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
3033
- * so peer identity and owner key are one and the same. That key must be
3034
- * 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}
3035
3750
  * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
3036
3751
  * Failing loudly here is intentional: a silently-unpublished strand would run
3037
3752
  * as a local-only island that no peer could ever discover or join.
@@ -3040,16 +3755,148 @@ export class CadreNode {
3040
3755
  * {@link addStrand}).
3041
3756
  * @param type - `'o'` for open (default) or `'c'` for closed.
3042
3757
  * @param memberPrivateKey - Optional membership key for a closed strand.
3043
- * @throws if the node is not started, exposes no owner signing key, the id is blank, or
3044
- * 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.
3045
3764
  */
3046
3765
  async publishStrand(strandId, type = 'o', memberPrivateKey) {
3047
3766
  const signingKey = this.requireOwnerSigningKey(`publish strand ${strandId}`);
3048
3767
  // Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
3049
3768
  // trims too, and an untrimmed row would be unreachable by the same string.
3050
3769
  const trimmed = requireNonBlank(strandId, 'strand id');
3051
- 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
+ }
3052
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;
3053
3900
  }
3054
3901
  /**
3055
3902
  * Remove this party's `Strand` row from the shared control database — the owner-signed
@@ -3254,19 +4101,49 @@ export class CadreNode {
3254
4101
  * seed, starts the strand, and registers it with the hibernation manager
3255
4102
  * before emitting `strand:started`.
3256
4103
  *
3257
- * Idempotent no-op when the strand manager already tracks `strand.Id` — the
3258
- * ordinary founding sequence is `addStrand` (starts it locally) followed by
3259
- * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
3260
- * rediscovers a row it already started and calls this again via
3261
- * `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
3262
4113
  * (already-connected) cohort seed and re-emit `strand:started` for an
3263
4114
  * instance that never stopped. The guard runs before the cohort-seed RPC
3264
- * 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.
3265
4123
  */
3266
4124
  async launchStrand(strand, sAppConfig, founder) {
4125
+ const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
3267
4126
  const existing = this.strandManager.getInstance(strand.Id);
3268
4127
  if (existing) {
3269
- 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
+ }
3270
4147
  return existing;
3271
4148
  }
3272
4149
  // Each strand node gets its own transport identity, derived from the cadre
@@ -3304,8 +4181,18 @@ export class CadreNode {
3304
4181
  bootstrapNodes,
3305
4182
  requireSignedSchemas: this.config.requireSignedSchemas,
3306
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`.
3307
4191
  backfill: this.config.strandBackfill,
3308
- 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
3309
4196
  });
3310
4197
  // The seed reached the node as `bootstrapNodes`, which only enters the
3311
4198
  // address book via @libp2p/bootstrap discovery. Merge it directly too, so a
@@ -3345,6 +4232,20 @@ export class CadreNode {
3345
4232
  * for a relay we are not yet connected to.
3346
4233
  */
3347
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) {
3348
4249
  if (!this.controlDatabase || !this.controlNode) {
3349
4250
  return [];
3350
4251
  }
@@ -3406,11 +4307,25 @@ export class CadreNode {
3406
4307
  }
3407
4308
  /**
3408
4309
  * The circuit relays this node's strand nodes would reserve through: the
3409
- * union of the RESOLVED listen addrs' circuits — a hand-written
3410
- * `network.listenAddrs` circuit entry or a `network.relayAddrs` entry, which
3411
- * the strand node inherits verbatim either way — and the control node's own
3412
- * live `/p2p-circuit` multiaddrs (a reservation this node discovered rather
3413
- * than configured).
4310
+ * union of CONFIGURED relays — `network.relayAddrs`, plus any hand-written
4311
+ * `network.listenAddrs` circuit entry, both of which a strand node inherits
4312
+ * verbatim — and the control node's own live `/p2p-circuit` multiaddrs (a
4313
+ * reservation this node discovered rather than configured).
4314
+ *
4315
+ * The CONFIGURED half reads `network.relayAddrs` and `network.listenAddrs`
4316
+ * directly rather than going through {@link resolveListenAddrs}: the control
4317
+ * node's resolution takes the `'search'` route, whose bare `/p2p-circuit` entry
4318
+ * names no relay, so a configured relay would be unannounceable here until this
4319
+ * node's own reservation landed — and the announce must be in place BEFORE a
4320
+ * strand node dials its reservation, since the relay's membership gate does not
4321
+ * know the strand's derived peerId (see delegate-admission.ts). Configuration is
4322
+ * authoritative; the live multiaddrs only ADD relays nobody configured.
4323
+ *
4324
+ * The `listenAddrs` half is now unreachable on a control node — `relay-addrs.ts`
4325
+ * rejects a hand-written `<relay>/p2p-circuit` listen entry outright, because the
4326
+ * bring-up quiet period denies the dial that listener makes from inside
4327
+ * `libp2p.start()`. Kept because it costs one spread and this is the one place
4328
+ * that would silently stop announcing if that rejection is ever relaxed.
3414
4329
  *
3415
4330
  * NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
3416
4331
  * source) gets no announcement, and a membership-gated one will deny it; fine
@@ -3418,7 +4333,8 @@ export class CadreNode {
3418
4333
  */
3419
4334
  circuitRelayTargets() {
3420
4335
  return extractCircuitRelayTargets([
3421
- ...(resolveListenAddrs(this.config.network) ?? []),
4336
+ ...relayCircuitAddrs(this.config.network?.relayAddrs ?? []),
4337
+ ...(this.config.network?.listenAddrs ?? []),
3422
4338
  ...(this.controlNode?.getMultiaddrs().map(String) ?? [])
3423
4339
  ]);
3424
4340
  }
@@ -3536,35 +4452,41 @@ export class CadreNode {
3536
4452
  if (due.length === 0) {
3537
4453
  return;
3538
4454
  }
3539
- // A due strand with nobody to ask leaves its throttle unstamped (see below),
3540
- // so this pass re-enters on EVERY 15 s reconcile tick for as long as the node
3541
- // is alone — which is the steady state of a solo node running a strand.
3542
4455
  // `connectedSiblingTargets`' membership read is unbounded, and with zero
3543
- // connections its answer is empty whatever the table holds, so decide it here
3544
- // from the connection list instead of paying for the read once a tick.
3545
- if (this.controlNode.getConnections().length === 0) {
3546
- return;
3547
- }
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
+ //
3548
4461
  // NOTE: one strand-addr RPC per (due strand × connected sibling) per refresh
3549
4462
  // interval — each a tiny request/response on an already-open control
3550
4463
  // connection. If a node ever runs MANY strands at once, batch the RPC to
3551
4464
  // carry several strand ids per request rather than one fan-out per strand.
3552
- const targets = await this.connectedSiblingTargets().catch((error) => {
3553
- log('refreshStrandPeerAddrs: sibling enumeration failed (skipping pass): %o', error);
3554
- return [];
3555
- });
3556
- // No sibling to ask (or a shutdown landed mid-enumeration): leave every stamp
3557
- // untouched so the next reconcile tick retries, rather than sitting out the
3558
- // whole refresh interval having asked nobody.
3559
- 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) {
3560
4473
  return;
3561
4474
  }
3562
4475
  await Promise.all(due.map((strandId) => this.refreshOneStrandPeerAddrs(strandId, targets, now)));
3563
4476
  }
3564
4477
  /**
3565
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, then
3566
- * merge their answers into this strand's address book. Errors are logged and
3567
- * 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.
3568
4490
  */
3569
4491
  async refreshOneStrandPeerAddrs(strandId, targets, now) {
3570
4492
  const controlNode = this.controlNode;
@@ -3572,13 +4494,19 @@ export class CadreNode {
3572
4494
  if (!controlNode || !strandNode) {
3573
4495
  return;
3574
4496
  }
4497
+ const contactAddrs = this.crossPartyStrandAddrs.get(strandId) ?? [];
4498
+ if (targets.length === 0 && contactAddrs.length === 0) {
4499
+ return;
4500
+ }
3575
4501
  try {
3576
4502
  // The running strand node's own peerId is the delegate to announce — see
3577
4503
  // the relationship note on refreshStrandPeerAddrs.
3578
- const addrs = await collectStrandAddrs(controlNode, [...targets], strandId, {
3579
- delegatePeerId: strandNode.peerId.toString()
3580
- });
3581
- // 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
3582
4510
  // what the throttle exists to bound, and an all-empty round is a normal
3583
4511
  // steady state for a strand no connected sibling currently runs.
3584
4512
  this.strandPeerAddrRefreshAt.set(strandId, now);
@@ -3587,7 +4515,7 @@ export class CadreNode {
3587
4515
  if (!this._running || this.strandManager.getInstance(strandId)?.libp2pNode !== strandNode) {
3588
4516
  return;
3589
4517
  }
3590
- await this.mergeStrandPeerAddrs(strandNode, addrs, strandId);
4518
+ await this.mergeStrandPeerAddrs(strandNode, unionAddrs(siblingAddrs, contactAddrs), strandId);
3591
4519
  }
3592
4520
  catch (error) {
3593
4521
  log('refreshStrandPeerAddrs: strand %s refresh failed (continuing): %o', strandId, error);
@@ -3611,6 +4539,15 @@ export class CadreNode {
3611
4539
  * handshake, and a bad entry costs one failed dial that ages out at the
3612
4540
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
3613
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.
3614
4551
  */
3615
4552
  async mergeStrandPeerAddrs(strandNode, addrs, strandId) {
3616
4553
  const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
@@ -3652,7 +4589,29 @@ export class CadreNode {
3652
4589
  if (!node) {
3653
4590
  return [];
3654
4591
  }
3655
- 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);
3656
4615
  }
3657
4616
  /**
3658
4617
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
@@ -3869,16 +4828,60 @@ export class CadreNode {
3869
4828
  getControlNode() {
3870
4829
  return this.controlNode;
3871
4830
  }
4831
+ /**
4832
+ * The boot-path half of {@link reserveRelays}: reserve through every
4833
+ * `network.relayAddrs` entry, or fail `start()`.
4834
+ *
4835
+ * WHY IT RUNS HERE, AT THE END. `network.relayAddrs` used to resolve to a
4836
+ * CONFIGURED circuit listener, which libp2p dials from inside `libp2p.start()`
4837
+ * — so the relay was already a connected peer, and therefore already in this
4838
+ * node's Optimystic cohort, before `ControlDatabase.initialize()` ran. Building
4839
+ * that database is a long chain of cohort-consulting block probes (catalog
4840
+ * hydration, then one per table, then the indexes), and a sibling that has not
4841
+ * yet replicated this node's `CadrePeer` row correctly refuses every one of
4842
+ * them, so bring-up died on `BlockUnavailableError` — deterministically, on
4843
+ * every boot of a relay-only node. Retrying could not converge either: the
4844
+ * condition that clears the refusal is this node's own row reaching the
4845
+ * sibling, and writing that row needs the database the retry is building.
4846
+ *
4847
+ * So the invariant is ordering, not retrying: the control database is built
4848
+ * while this node holds ZERO control connections (a cohort of one, entirely
4849
+ * local), and only then does it reach out. Everything above in {@link start}
4850
+ * has completed by the time this runs.
4851
+ *
4852
+ * BUDGET: the supervisor's first attempt — `relay-reservation.ts`'s
4853
+ * `DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s — is what `start()` waits on — deliberately the module default rather
4854
+ * than a boot-specific one. A healthy dial-plus-reserve is sub-second even over
4855
+ * a WAN, so 10 s is slack for a slow link; going much longer would make a dead
4856
+ * relay indistinguishable from a hung start, and much shorter would fail nodes
4857
+ * on links that were merely slow. The retries carry on in the background after
4858
+ * this resolves, exactly as they do for a {@link reserveRelays} caller.
4859
+ */
4860
+ async driveControlRelayReservation() {
4861
+ const relayAddrs = this.config.network?.relayAddrs ?? [];
4862
+ if (relayAddrs.length === 0) {
4863
+ return;
4864
+ }
4865
+ const t0 = performance.now();
4866
+ const state = await this.reserveRelays([...relayAddrs]);
4867
+ timing('[start] reserveRelays: %dms', Math.round(performance.now() - t0));
4868
+ if (state.status !== 'reserved') {
4869
+ throw new RelayReservationFailedError(relayAddrs, state);
4870
+ }
4871
+ log('Reserved a relay slot: %o', state.circuitAddrs);
4872
+ }
3872
4873
  /**
3873
4874
  * Start keeping a relay reservation: dial the given relay(s) from the control
3874
4875
  * node, ask the first one that answers for a reservation slot, wait until the
3875
4876
  * resulting `/p2p-circuit` address makes this node dialable — and keep a
3876
4877
  * supervisor running that re-drives whenever the reservation is later lost.
3877
4878
  *
3878
- * For nodes that listen on the bare `/p2p-circuit` SEARCH addr (a browser tab)
3879
- * rather than a configured `network.relayAddrs` circuit listener — see
3880
- * `relay-reservation.ts` for why the two are alternatives and why configuring
3881
- * both is a regression. Opt-in: nothing calls this for the CLI/host nodes.
4879
+ * The FAIL-SOFT entry point, for a caller that discovers its relay at runtime
4880
+ * (a browser tab, a host UI). A node that names `network.relayAddrs` gets the
4881
+ * same drive from {@link driveControlRelayReservation} at the end of
4882
+ * {@link start}, which is fail-fast instead. Both fill the pending reservation
4883
+ * the bare `/p2p-circuit` search listener registered; calling this afterwards
4884
+ * simply replaces the supervisor with one over the new list.
3882
4885
  *
3883
4886
  * Resolves once the FIRST attempt has settled, exactly as it did when it drove
3884
4887
  * once; the retries continue in the background until {@link stop} or the next
@@ -4011,6 +5014,10 @@ export class CadreNode {
4011
5014
  * Push the multiaddrs that future invites should advertise. Pass `null` to
4012
5015
  * revert to the libp2p-reported addresses (the default). The host calls this
4013
5016
  * at spawn and on every NAT change.
5017
+ *
5018
+ * Entries need NOT carry a `/p2p/<peerId>` suffix — {@link resolveInviteAddresses}
5019
+ * appends this node's own before anything publishes or dials them (see
5020
+ * {@link normalizeSelfAddrs}). Passing them suffixed is equally fine.
4014
5021
  */
4015
5022
  setInviteAddresses(addresses) {
4016
5023
  this.latestInviteAddresses = addresses;
@@ -4018,17 +5025,59 @@ export class CadreNode {
4018
5025
  }
4019
5026
  /**
4020
5027
  * Resolve the addresses to embed in invites. Prefers pushed addresses, then
4021
- * any config-supplied resolver, then the libp2p-observed multiaddrs.
5028
+ * any config-supplied resolver, then the libp2p-observed multiaddrs. The two
5029
+ * app-supplied sources are normalized onto `/p2p/<self>` (see
5030
+ * {@link normalizeSelfAddrs}) so neither hook has to remember the suffix;
5031
+ * libp2p's own addresses already carry it.
4022
5032
  */
4023
5033
  async resolveInviteAddresses() {
4024
5034
  if (this.latestInviteAddresses !== null) {
4025
- return this.latestInviteAddresses;
5035
+ return this.normalizeSelfAddrs(this.latestInviteAddresses);
4026
5036
  }
4027
5037
  if (this.config.network?.inviteAddressResolver) {
4028
- return this.config.network.inviteAddressResolver();
5038
+ return this.normalizeSelfAddrs(await this.config.network.inviteAddressResolver());
4029
5039
  }
5040
+ // Not normalized: this branch is libp2p's own output, which already
5041
+ // encapsulates the peer id into every address it reports. Only the two app
5042
+ // hooks above take arbitrary strings and therefore need the guarantee.
4030
5043
  return this.getMultiaddrs();
4031
5044
  }
5045
+ /**
5046
+ * Normalize addresses an app handed us for THIS node onto `/p2p/<self>`, the
5047
+ * same invariant {@link normalizeDialAddrs} enforces on the way out.
5048
+ *
5049
+ * `setInviteAddresses` (the admin API `PUT /admin/invite-addresses`) and
5050
+ * `network.inviteAddressResolver` both take arbitrary strings, and whatever
5051
+ * they return ends up in this node's published `CadrePeer` record — so ONE
5052
+ * unsuffixed entry from either hook is enough to give every sibling in the
5053
+ * party a mixed candidate list for this peer. Establishing the suffix here,
5054
+ * where those strings enter the system, means neither hook has to know the
5055
+ * rule; `cadre-host`'s `buildInviteAddresses` already appends it, and
5056
+ * re-normalizing an already-suffixed address is a no-op.
5057
+ *
5058
+ * Unparsable and other-peer-addressed entries are passed through untouched
5059
+ * rather than dropped: publication is not the place to police an address's
5060
+ * validity, and {@link resolvePeerAddrs} already drops both on the read side.
5061
+ * Before `start()` resolves an identity there is no peer id to append, so the
5062
+ * list is returned as given.
5063
+ */
5064
+ normalizeSelfAddrs(addrs) {
5065
+ const selfPeerId = this.peerId?.toString();
5066
+ if (!selfPeerId) {
5067
+ return addrs;
5068
+ }
5069
+ return addrs.map((addr) => {
5070
+ let parsed;
5071
+ try {
5072
+ parsed = multiaddr(addr);
5073
+ }
5074
+ catch (error) {
5075
+ log('normalizeSelfAddrs: leaving unparsable multiaddr %s as-is: %o', addr, error);
5076
+ return addr;
5077
+ }
5078
+ return withTrailingPeerId(parsed, selfPeerId)?.toString() ?? addr;
5079
+ });
5080
+ }
4032
5081
  /**
4033
5082
  * Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
4034
5083
  * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
@@ -4090,13 +5139,18 @@ export class CadreNode {
4090
5139
  * legit member goes un-authorized on readers that only pin the new key. Full
4091
5140
  * rotation handling (re-vouch on rotate) is the
4092
5141
  * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
5142
+ *
5143
+ * @param retry - Whether the underlying membership read may retry a transient cluster
5144
+ * failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
5145
+ * as the control database's membership listener with that database's write lock held;
5146
+ * see its comment.
4093
5147
  */
4094
- async listAuthorizedMembers() {
5148
+ async listAuthorizedMembers(retry = true) {
4095
5149
  if (!this.controlDatabase) {
4096
5150
  throw new Error('CadreNode must be started before listing members');
4097
5151
  }
4098
5152
  const selfPeerId = this.peerId?.toString();
4099
- const rows = await this.controlDatabase.queryCadrePeers();
5153
+ const rows = await this.controlDatabase.queryCadrePeers(retry);
4100
5154
  const authorized = rows
4101
5155
  .filter(row => row.peerId !== selfPeerId && this.hasAnchoredVoucher(row))
4102
5156
  .map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
@@ -4260,6 +5314,25 @@ export class CadreNode {
4260
5314
  await this.membershipGateDrain;
4261
5315
  }
4262
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
+ }
4263
5336
  /**
4264
5337
  * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
4265
5338
  * until the stale flag stays clear, then release the slot.
@@ -4571,7 +5644,11 @@ export class CadreNode {
4571
5644
  this.strandSolicitationService = new StrandSolicitationService({
4572
5645
  ...options,
4573
5646
  partyId: this.config.controlNetwork.partyId,
4574
- 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)
4575
5652
  });
4576
5653
  // Register as responder on the control node
4577
5654
  this.strandSolicitationService.registerResponder(this.controlNode);
@@ -4616,7 +5693,41 @@ export class CadreNode {
4616
5693
  if (!this.strandSolicitationService) {
4617
5694
  this.initializeStrandSolicitation();
4618
5695
  }
4619
- 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);
4620
5731
  }
4621
5732
  /**
4622
5733
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).