@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
@@ -1,9 +1,10 @@
1
1
  import type { Libp2p, PeerId } from '@libp2p/interface';
2
2
  import type { Multiaddr } from '@multiformats/multiaddr';
3
- import type { CadreNodeConfig, StrandInstance, StrandConfig, SAppConfig, CadreNodeEvents, ControlNetworkSeed, ApplySeedResult, AddDroneOptions, AddPhoneOptions, DroneInitResult, InviteResult, CadreInvite, OpenInvitation, FormStrandResult, StrandFormationDisclosure, ResolveOpts, SelfRegistrationOutcome, ServiceWakeResult, PushPlatform, DeviceTokenRecord, ResolveDeviceTokenOpts } from './types.js';
3
+ import type { CadreNodeConfig, StrandInstance, StrandRow, StrandConfig, FoundStrandConfig, FoundStrandResult, SAppConfig, CadreNodeEvents, ControlNetworkSeed, ApplySeedResult, AddDroneOptions, AddPhoneOptions, DroneInitResult, InviteResult, CadreInvite, OpenInvitation, FormStrandResult, StrandFormationDisclosure, ResolveOpts, SelfRegistrationOutcome, ServiceWakeResult, PushPlatform, DeviceTokenRecord, ResolveDeviceTokenOpts } from './types.js';
4
4
  import { type Ed25519KeyPair } from './ed25519-key.js';
5
5
  import { type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
6
6
  import { type BootstrapPeerStore } from './bootstrap-peer-store.js';
7
+ import { type EnrolledMachineStore } from './enrolled-machine-store.js';
7
8
  import { type SAppIdLookup } from './strand-watcher.js';
8
9
  import { EnrollmentService } from './enrollment.js';
9
10
  import { ControlDatabase } from './control-database.js';
@@ -57,6 +58,14 @@ export declare class CadreNode implements SAppIdLookup {
57
58
  private trustedOwnerStore;
58
59
  private controlNode;
59
60
  private controlDatabase;
61
+ /**
62
+ * The CONTROL network's peer-join block catch-up — same module as the
63
+ * per-strand ones, but membership-gated (see {@link startControlBackfill}).
64
+ * Created in {@link start} after the control node and database are up, stopped
65
+ * and dropped in {@link cleanup} before either is torn down, so a stop()→
66
+ * start() cycle rebuilds it with a fresh caught-up-peer memo.
67
+ */
68
+ private controlBackfill;
60
69
  private strandWatcher;
61
70
  private strandManager;
62
71
  private hibernationManager;
@@ -103,10 +112,11 @@ export declare class CadreNode implements SAppIdLookup {
103
112
  private windowWaiters;
104
113
  private eventHandlers;
105
114
  /**
106
- * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through.
107
- * Empty (the default) means nobody asked, so {@link getRelayReservationState}
108
- * reports `none` — this whole surface is opt-in and dormant for the CLI/host
109
- * nodes, which use the configured `network.relayAddrs` route instead.
115
+ * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through —
116
+ * or, on a node that names `network.relayAddrs`, the ones
117
+ * {@link driveControlRelayReservation} asked for at the end of {@link start}.
118
+ * Empty (the default) means neither happened, so {@link getRelayReservationState}
119
+ * reports `none`.
110
120
  */
111
121
  private relayReserveAddrs;
112
122
  /**
@@ -121,6 +131,23 @@ export declare class CadreNode implements SAppIdLookup {
121
131
  * supervisor to hold one — i.e. the pre-start `control node unavailable` case.
122
132
  */
123
133
  private relayReserveError;
134
+ /**
135
+ * True from just before the control libp2p node is created until
136
+ * {@link ControlDatabase.initialize} has settled — the connection gate's
137
+ * BRING-UP QUIET PERIOD (`membership-connection-gater.ts`), during which this
138
+ * node refuses every control connection in both directions.
139
+ *
140
+ * The invariant: the control database is built while this node holds zero
141
+ * control connections. Every same-party sibling connected in that window joins
142
+ * the Optimystic cohort the bring-up's block probes consult, and a sibling that
143
+ * has not yet replicated this node's `CadrePeer` row refuses them all — which
144
+ * fails `start()` outright and cannot be retried into convergence (writing the
145
+ * row that would clear the refusal needs the database being built).
146
+ *
147
+ * Cleared on FAILURE as well as success ({@link cleanup} clears it), so
148
+ * teardown is never gated.
149
+ */
150
+ private controlBringUpInFlight;
124
151
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
125
152
  private sAppConfigs;
126
153
  /**
@@ -181,12 +208,25 @@ export declare class CadreNode implements SAppIdLookup {
181
208
  /**
182
209
  * In-memory delegate admission grants (see `delegate-admission.ts`): the
183
210
  * strand-node transport peerIds this party's members have announced over the
184
- * strand-addr RPC, admitted at the CONNECTION level so a member's NAT'd
185
- * strand node can hold a circuit-relay reservation on this node. Consulted
186
- * ONLY by {@link admitInboundControlConnection}; the fail-closed per-stream
187
- * gate ({@link authorizeInboundControlStream}) never honors it.
211
+ * strand-addr RPC, admitted at the CONNECTION and RESERVATION levels so a
212
+ * member's NAT'd strand node can hold a circuit-relay reservation on this
213
+ * node (without spending the unauthorized-reservation budget). Consulted
214
+ * ONLY by {@link admitInboundControlConnection} and
215
+ * {@link admitControlRelayReservation}; the fail-closed per-stream gate
216
+ * ({@link authorizeInboundControlStream}) never honors it.
188
217
  */
189
218
  private readonly delegateAdmission;
219
+ /**
220
+ * Bounded budget of concurrent circuit-relay reservations granted to peers
221
+ * this node cannot (yet) place as members — the boot-ordering window where a
222
+ * genuine sibling reserves before its `CadrePeer` row has replicated here.
223
+ * Consulted only by {@link admitControlRelayReservation} (the gater's
224
+ * `denyInboundRelayReservation` policy); authorized members and announced
225
+ * delegates are admitted before it and never counted. Cap from
226
+ * `network.unauthorizedRelayReservationCap` (default
227
+ * `MAX_UNAUTHORIZED_RELAY_RESERVATIONS`).
228
+ */
229
+ private readonly unauthorizedRelayReservations;
190
230
  /**
191
231
  * When this node last ANNOUNCED a delegate, keyed `<targetPeerId>\n<strandId>`
192
232
  * (the peer announced TO, not the delegate). Throttles
@@ -206,6 +246,36 @@ export declare class CadreNode implements SAppIdLookup {
206
246
  * stale stamp.
207
247
  */
208
248
  private readonly strandPeerAddrRefreshAt;
249
+ /**
250
+ * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
251
+ * responder's live strand addrs, carried back on the formation result
252
+ * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
253
+ *
254
+ * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
255
+ * resolves a strand's addresses is membership-gated and answers own-party siblings
256
+ * only, so without this map a joiner's cohort seed for a two-party strand is empty and
257
+ * the mesh never forms.
258
+ *
259
+ * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
260
+ * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
261
+ * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
262
+ * them.
263
+ *
264
+ * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
265
+ * the process, and a restarted initiator with no sibling running the strand is back to
266
+ * an empty seed until the durability work lands
267
+ * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
268
+ * so a responder whose relay reservation rotates before the initiator dials leaves a
269
+ * dead entry behind; recovery today is a fresh invitation.
270
+ *
271
+ * NOTE: entries are kept for the node's lifetime, including across a
272
+ * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
273
+ * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
274
+ * number of KEYS is bounded only by the strands this node has ever formed as an
275
+ * initiator (not by time); if a node ever forms strands at scale, evict on
276
+ * `unpublishStrand` or cap the map.
277
+ */
278
+ private readonly crossPartyStrandAddrs;
209
279
  /**
210
280
  * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
211
281
  * this node has applied, keyed by peer id, valued by the seed's multiaddr
@@ -240,6 +310,34 @@ export declare class CadreNode implements SAppIdLookup {
240
310
  * the party if it is ever stranded again.
241
311
  */
242
312
  private bootstrapPeerStore;
313
+ /**
314
+ * Node-local record of the party's enrolled-machine count (see
315
+ * `enrolled-machine-store.ts`), kept so the CONTROL node can declare a
316
+ * block-repair yardstick at bring-up. Constructed (or adopted from
317
+ * `config.enrolledMachines.store`) by {@link initializeEnrolledMachineStore};
318
+ * written by {@link refreshAuthorizedControlPeers}. Like
319
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup},
320
+ * so it survives a stop()→start() cycle of the same node instance.
321
+ */
322
+ private enrolledMachineStore;
323
+ /**
324
+ * The enrolled-machine count this node's CONTROL libp2p node was (or will be)
325
+ * built with — read out of {@link enrolledMachineStore} during {@link start},
326
+ * before {@link createControlNode}, and consumed by
327
+ * {@link buildControlNodeOptions}.
328
+ *
329
+ * A captured FIELD rather than a live `store.count()` read, deliberately: the
330
+ * store moves as membership changes, but Optimystic froze the policy when the
331
+ * node was built, so this field is the honest answer to "what did this node
332
+ * actually declare?" and does not drift away from the running node. It is
333
+ * re-read on each {@link start}, which is what makes a stop()→start() cycle pick
334
+ * up a count recorded during the previous run.
335
+ *
336
+ * `undefined` — a brand-new node, an unreadable slot, or the ephemeral default
337
+ * store — means "this node does not know", which `controlClusterPolicy` answers
338
+ * with the frozen base policy itself.
339
+ */
340
+ private declaredEnrolledMachines;
243
341
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
244
342
  private selfRegistrationTimer;
245
343
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -471,6 +569,32 @@ export declare class CadreNode implements SAppIdLookup {
471
569
  * must never enter this node's dial loop.
472
570
  */
473
571
  private initializeBootstrapPeerStore;
572
+ /**
573
+ * Construct (or adopt) the node-local enrolled-machine store and capture the
574
+ * count this run will declare into {@link declaredEnrolledMachines}.
575
+ *
576
+ * Synchronous for the same reason {@link initializeBootstrapPeerStore} is: an
577
+ * injected store has already loaded its persisted count by the time it is handed
578
+ * in (its `open` is the async part), and the in-memory default has nothing to
579
+ * load. Must run BEFORE {@link createControlNode} — that is the whole point of
580
+ * the record; see `enrolled-machine-store.ts`.
581
+ *
582
+ * The store instance is kept across stop()→start() (so a count recorded during
583
+ * the previous run is not lost with the ephemeral default), but the DECLARED
584
+ * count is re-read every time — which is how a restart applies a number the
585
+ * previous run learned.
586
+ *
587
+ * A store scoped to a different party is a configuration error, fail closed. It
588
+ * is only a repair hint, but a hint sized by a foreign party's membership is a
589
+ * number nobody chose, and the mismatch always means a miswired embedder.
590
+ */
591
+ private initializeEnrolledMachineStore;
592
+ /**
593
+ * The node-local enrolled-machine store (null before {@link start}) — what this
594
+ * node last knew about its party's size. Exposed for diagnostics and for a host
595
+ * that wants to show which repair yardstick the next launch will declare.
596
+ */
597
+ getEnrolledMachineStore(): EnrolledMachineStore | null;
474
598
  /**
475
599
  * The node-local cold-start bootstrap-peer store (null before {@link start}) —
476
600
  * the retained owner addresses {@link reconcileControlCohort}'s cold-start
@@ -503,6 +627,44 @@ export declare class CadreNode implements SAppIdLookup {
503
627
  */
504
628
  trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void>;
505
629
  private createControlNode;
630
+ /**
631
+ * The control libp2p node's Optimystic network name — ONE binding for every
632
+ * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
633
+ * `/optimystic/<networkName>/...`), so the node options and the block-transfer
634
+ * protocol prefix the control backfill dials can never drift apart.
635
+ */
636
+ private controlNetworkName;
637
+ /**
638
+ * Arm the CONTROL network's peer-join block catch-up: push every block in the
639
+ * control database's own raw store to each newly connected AUTHORIZED member
640
+ * this runtime has not yet caught up. This is what physically replicates
641
+ * control blocks committed while the writer was alone — the named
642
+ * collection-header blocks written once at genesis above all, whose revision
643
+ * never moves again, so no later commit ever carries them to a member that
644
+ * joined after them. Without it, such a member that restarts offline reads
645
+ * the affected control tables as EMPTY, silently (`isMember()` answers false
646
+ * for peers it knew about before the restart).
647
+ *
648
+ * Unlike the per-strand instances (see `strand-instance-manager.ts`), pushes
649
+ * are gated on {@link isAuthorizedMember}, judged at push time: the control
650
+ * network's inbound connection gate deliberately admits non-members in
651
+ * several states (seed delivery to an un-enrolled node, an open enrollment
652
+ * window, an outstanding invitation, configured bootstrap/relay peers), and
653
+ * pushing the whole control store to such a peer would hand a stranger the
654
+ * party's entire membership, addresses and strand list. The receiving side
655
+ * needs no work of its own: `createLibp2pNode` registers the block-transfer
656
+ * handler on every node it builds, control node included, and this node's
657
+ * per-stream gate (`authorizeInboundControlStream`) covers the inbound
658
+ * direction. A denied peer is retried on its next `connection:open`, and —
659
+ * because the production join order is connect-then-authorize — on every
660
+ * committed membership change via {@link refreshAuthorizedControlPeers}'s
661
+ * `scheduleConnectedPeers()` call.
662
+ *
663
+ * No-ops (logged) when the embedder configured no control storage or the
664
+ * node exposes no key network — the backfill would have nothing to read or
665
+ * no way to dial.
666
+ */
667
+ private startControlBackfill;
506
668
  /**
507
669
  * The control database's cache-wrapped raw storage, resolved from
508
670
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -522,6 +684,27 @@ export declare class CadreNode implements SAppIdLookup {
522
684
  * operation count.
523
685
  */
524
686
  private resolveControlStorage;
687
+ /**
688
+ * Warn the operator when `network.announceAddrs` will silently cost this node the
689
+ * relay reachability it also configured.
690
+ *
691
+ * A non-empty announce set REPLACES everything libp2p advertises, so the
692
+ * `/p2p-circuit` address earned by a relay reservation is dropped from the node's
693
+ * advertised addresses even though the reservation itself is still held — peers
694
+ * behind NAT stop being able to reach it through that relay. Not an error: an
695
+ * operator whose relay slot is decorative may genuinely want only the announced
696
+ * address, so this reports and proceeds.
697
+ *
698
+ * Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
699
+ * hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
700
+ * longer route — is caught too.
701
+ *
702
+ * NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
703
+ * diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
704
+ * second such warning ever appears here, route both through a `CadreNodeEvents` entry the
705
+ * embedder surfaces instead of growing a console surface inside a library.
706
+ */
707
+ private warnIfAnnounceAddrsDiscardRelay;
525
708
  /**
526
709
  * Map this node's config onto the control network's libp2p node options.
527
710
  *
@@ -531,6 +714,15 @@ export declare class CadreNode implements SAppIdLookup {
531
714
  * only caller in production is `createControlNode`.
532
715
  */
533
716
  private buildControlNodeOptions;
717
+ /**
718
+ * Does this node's CONTROL libp2p run the circuit-relay server? An explicit
719
+ * `network.enableRelay` wins; otherwise storage-profile nodes default to on
720
+ * (better connectivity/uptime). Shared by {@link buildControlNodeOptions}
721
+ * (which configures the server from it) and
722
+ * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch
723
+ * must agree with whether a reservation is even servable here).
724
+ */
725
+ private relayServerEnabled;
534
726
  /**
535
727
  * Decide whether an inbound CONTROL-network connection from `remotePeerId`
536
728
  * should be admitted — the policy behind the connection gater wired in
@@ -540,7 +732,7 @@ export declare class CadreNode implements SAppIdLookup {
540
732
  * `membership-connection-gater.ts` for the layer's rationale and the
541
733
  * stranger-open protocol allowlist).
542
734
  *
543
- * Admits when ANY of:
735
+ * Returns `'admit'` when ANY of:
544
736
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
545
737
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
546
738
  * configured bootstrap/relay infrastructure);
@@ -577,17 +769,69 @@ export declare class CadreNode implements SAppIdLookup {
577
769
  * this node yet is denied even though the formation handler would have
578
770
  * accepted it, exactly like the unreplicated-membership-row case below.
579
771
  *
772
+ * When every check falls through, the verdict depends on whether this node
773
+ * runs the circuit-relay server ({@link relayServerEnabled}): without one,
774
+ * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
775
+ * established by the reserving peer DIALING the relay, so a connection deny
776
+ * here kills the reservation, and that deny is NOT self-healing: an outbound
777
+ * reconcile re-dial re-establishes a data link, but no outbound dial can
778
+ * grant the remote peer a reservation, and a relay-only peer has no address
779
+ * of its own to dial back — the reservation IS its address. The gater admits
780
+ * such a connection, decides the reservation via
781
+ * {@link admitControlRelayReservation}, and drops the connection if no
782
+ * reservation is admitted in time (see `membership-connection-gater.ts` →
783
+ * "The relay-reservation seam").
784
+ *
580
785
  * NOTE: check 4/5 runs a control-DB read per inbound connection
581
786
  * (`listAuthorizedMembers`); connections are rare and cadres small, and this
582
787
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
583
788
  * read is safe here — unlike the per-stream gate, which must consult the
584
789
  * materialized {@link authorizedControlPeers} snapshot instead.
585
- * NOTE: a sibling whose membership row has not yet replicated to
586
- * this node is denied until the row converges (typically via the owner);
587
- * either side's next outbound reconcile dial (outbound is never gated)
588
- * re-establishes the link — self-healing, but visible as a transient deny.
790
+ * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
791
+ * replicated here is denied until the row converges (typically via the
792
+ * owner); either side's next outbound reconcile dial (outbound is never
793
+ * gated) re-establishes the DATA link — self-healing for data, visible as a
794
+ * transient deny. That self-healing story never covered a reservation, which
795
+ * is exactly why the relay-enabled path above exists.
589
796
  */
590
797
  private admitInboundControlConnection;
798
+ /**
799
+ * Should `remotePeerId` be granted a circuit-relay reservation slot on this
800
+ * node's relay server? The policy behind the gater's
801
+ * `denyInboundRelayReservation` hook (see `membership-connection-gater.ts` →
802
+ * "The relay-reservation seam"); the circuit-relay server consults it per
803
+ * RESERVE request, so it is never called on a node whose relay server is off.
804
+ *
805
+ * Admits when ANY of:
806
+ * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
807
+ * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
808
+ * configured bootstrap/relay infrastructure);
809
+ * 2. the peer holds a live delegate admission grant — a member's strand
810
+ * node reserving here (see `delegate-admission.ts`); never counted
811
+ * against the unauthorized budget;
812
+ * 3. the authorized-member set is empty (cold start) or the peer IS an
813
+ * authorized member — members are never counted against the budget; or
814
+ * 4. the peer takes (or already holds) a slot in the bounded
815
+ * unauthorized-reservation budget — the boot-ordering window where a
816
+ * genuine member reserves before its `CadrePeer` row replicates here.
817
+ * Cap via `network.unauthorizedRelayReservationCap` (0 = refuse every
818
+ * unauthorized reservation).
819
+ *
820
+ * Every uncounted admission also RELEASES the budget slot the peer may hold
821
+ * from an earlier reservation taken while it was still unplaceable — a member
822
+ * that boots, reserves, and only then has its row replicate here would
823
+ * otherwise keep that slot spent for the rest of the entry's TTL.
824
+ *
825
+ * The connection gate's stranger carve-outs (an open enrollment window, an
826
+ * outstanding formation invitation) deliberately do NOT extend here: they
827
+ * exist so a stranger's SEED or FORMATION stream can ride a connection, and
828
+ * neither needs relay capacity. A genuine invitee that does need a relay slot
829
+ * takes one from the budget like any other unplaced peer, so an open window
830
+ * never becomes an unbounded grant of this node's forwarding capacity.
831
+ */
832
+ private admitControlRelayReservation;
833
+ /** Admit a reservation on the peer's own merits, giving back any budget slot it still holds. */
834
+ private admitReservationUncounted;
591
835
  /**
592
836
  * Hold the control-network inbound gate open for not-yet-authorized peers
593
837
  * until `untilEpochMs` (extends, never shrinks, an already-open window).
@@ -599,7 +843,8 @@ export declare class CadreNode implements SAppIdLookup {
599
843
  /**
600
844
  * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
601
845
  * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
602
- * at the CONNECTION level (all a circuit-relay reservation needs) for
846
+ * at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
847
+ * needs, without spending the unauthorized-reservation budget) for
603
848
  * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
604
849
  * authorized-membership gate has passed; public so tests can drive the
605
850
  * admission policy without a full strand launch. A re-announce for the same
@@ -677,6 +922,12 @@ export declare class CadreNode implements SAppIdLookup {
677
922
  *
678
923
  * The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
679
924
  * so two reads can no longer settle out of order.
925
+ *
926
+ * Reads WITHOUT the transient-failure retry, deliberately: `ControlDatabase` drives
927
+ * this listener with its write lock HELD (`notifyMembershipChanged`), so a retrying
928
+ * read here would sleep its backoff holding the lock and stall every other local
929
+ * writer. Nothing is lost — this refresh already keeps the previous snapshot on
930
+ * failure and is re-driven by the next membership write and by the timed reconcile.
680
931
  */
681
932
  private refreshAuthorizedControlPeers;
682
933
  /**
@@ -737,6 +988,24 @@ export declare class CadreNode implements SAppIdLookup {
737
988
  * producing an unresolvable row.
738
989
  */
739
990
  private getSelfSigningKey;
991
+ /**
992
+ * Does the `Strand` row name THIS machine as its founder — i.e. is its
993
+ * `FounderOwnerKey` this node's own owner key (the key behind its PeerId)?
994
+ * A null/absent column (a consent-seated strand, or a hand-built row) and a
995
+ * node with no owner key both derive `false`: without a positive match this
996
+ * machine must attach, never bootstrap. Pure key derivation, no I/O — cheap
997
+ * enough for {@link launchStrand}'s tracked-instance early return.
998
+ *
999
+ * NOTE: reads "one owner key per machine" — the reference model, where the owner
1000
+ * key IS the key behind the PeerId. Two machines running the SAME identity key
1001
+ * would both derive `true` and each bootstrap on its own replica, which is the
1002
+ * double-`Header` hazard this derivation exists to avoid. Unreachable today: that
1003
+ * configuration also gives both machines one PeerId, which already breaks control
1004
+ * networking well before any strand launches. Revisit if machines ever share an
1005
+ * owner key while holding distinct transport identities — the derivation would
1006
+ * then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
1007
+ */
1008
+ private isSelfFoundedRow;
740
1009
  /**
741
1010
  * The owner keypair (base64url Ed25519) derived from this node's resolved
742
1011
  * identity key. In the single-key reference model the owner signing key is
@@ -787,8 +1056,49 @@ export declare class CadreNode implements SAppIdLookup {
787
1056
  * Survivors are returned signaling (`/p2p-circuit`) first, filtered to
788
1057
  * signaling-only when requested, as parsed `Multiaddr`s (unparsable addrs
789
1058
  * dropped). Any gate failure yields an empty array rather than throwing.
1059
+ *
1060
+ * Every returned address is normalized to terminate in `/p2p/<peerId>` — see
1061
+ * {@link normalizeDialAddrs} for why that invariant, not each dial site's own
1062
+ * handling, is what keeps a mixed list from taking a whole peer offline.
790
1063
  */
791
1064
  resolvePeerAddrs(peerId: string, opts?: ResolveOpts): Promise<Multiaddr[]>;
1065
+ /**
1066
+ * Guarantee every address handed to a control-network dial terminates in
1067
+ * `/p2p/<peerId>`, with no duplicates — the invariant every dial path
1068
+ * downstream relies on.
1069
+ *
1070
+ * `libp2p.dial(addrs)` requires that the addresses in ONE dial either all name
1071
+ * a peer id or none do; a list mixing a circuit address (which ends in
1072
+ * `/p2p/<target>`) with a bare direct one (which ends in `/tcp/…`) makes that
1073
+ * call throw, and the whole peer is then silently skipped rather than one bad
1074
+ * address.
1075
+ *
1076
+ * Applied by ALL THREE sources of control-dial candidates, so no call site
1077
+ * invents its own rule and none can produce a mixed list: the signed record
1078
+ * ({@link resolvePeerAddrs}), the libp2p address book
1079
+ * ({@link peerStoreAddrs}), and a seed's retained owner addresses
1080
+ * ({@link bootstrapDialAddrs}).
1081
+ *
1082
+ * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
1083
+ * `peerId`, so it does not belong in this peer's candidate list (libp2p's own
1084
+ * dial queue filters the same shape out one layer lower).
1085
+ */
1086
+ private normalizeDialAddrs;
1087
+ /**
1088
+ * One address bound to `peerId`, or `null` when it cannot be — logged either
1089
+ * way, never thrown, so every caller's list-shaping stays total.
1090
+ *
1091
+ * Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
1092
+ * seed-supplied bootstrap addr) and to normalize the ones it ANNOUNCES
1093
+ * ({@link getStrandMultiaddrs}); the rule is the same either way — an address
1094
+ * that does not reach `peerId` is not an address for `peerId`.
1095
+ *
1096
+ * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
1097
+ * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
1098
+ * above parsed it already) and for our own node's id, reachable for a
1099
+ * seed-supplied one ({@link bootstrapDialAddrs}).
1100
+ */
1101
+ private bindAddrToPeer;
792
1102
  /** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
793
1103
  private parseMultiaddrs;
794
1104
  /**
@@ -875,6 +1185,16 @@ export declare class CadreNode implements SAppIdLookup {
875
1185
  * Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
876
1186
  * {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
877
1187
  * parse/lookup failure — never throws.
1188
+ *
1189
+ * Normalized through {@link normalizeDialAddrs} for the same reason
1190
+ * {@link resolvePeerAddrs} is, and with more cause: the address book hands back
1191
+ * a list that is inherently MIXED. `@libp2p/peer-store`'s
1192
+ * `dedupeFilterAndSortAddresses` strips a trailing `/p2p/<peerId>` only when
1193
+ * that id is the address's FIRST `/p2p/` component, so a direct address
1194
+ * round-trips bare while a relayed one — whose first `/p2p/` names the relay —
1195
+ * keeps its suffix (the same asymmetry `peer-addr-book.ts`'s `addrKey` exists
1196
+ * to absorb). Feeding both to one `dial()` is exactly the
1197
+ * `InvalidParametersError` that skipped the whole peer.
878
1198
  */
879
1199
  private peerStoreAddrs;
880
1200
  /**
@@ -927,11 +1247,12 @@ export declare class CadreNode implements SAppIdLookup {
927
1247
  * Bind a bootstrap peer's seed addresses to its peer id, so the dial
928
1248
  * authenticates the peer it is aiming at rather than trusting whoever answers.
929
1249
  *
930
- * An address that already carries `/p2p/<id>` must carry THIS peer's id or it
931
- * is dropped (a seed that disagrees with itself is not a dial target); one
932
- * that carries none — an `addressOverrides`-published record, say — gets the
933
- * id encapsulated. Unencapsulatable addresses are dropped rather than dialed
934
- * bare.
1250
+ * The same {@link normalizeDialAddrs} rule the resolved and address-book paths
1251
+ * use, on the third and last source of control-dial candidates: an address that
1252
+ * already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
1253
+ * (a seed that disagrees with itself is not a dial target); one that does not —
1254
+ * a bare listen addr, or a relay hop with the destination missing — gets the id
1255
+ * encapsulated; an unencapsulatable one is dropped rather than dialed bare.
935
1256
  */
936
1257
  private bootstrapDialAddrs;
937
1258
  /** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
@@ -1180,6 +1501,12 @@ export declare class CadreNode implements SAppIdLookup {
1180
1501
  expireDeviceToken(peerId: string): Promise<void>;
1181
1502
  private handleStrandAdded;
1182
1503
  private handleStrandRemoved;
1504
+ /**
1505
+ * Return this node to its pre-{@link start} state. The ONE teardown — `stop()` and
1506
+ * a failed `start()` both come here, so anything a stopped node must not still be
1507
+ * doing belongs in this method and not in `stop()`. `stop()` adds only the
1508
+ * `control:disconnected` emit, which a never-connected start has no edge for.
1509
+ */
1183
1510
  private cleanup;
1184
1511
  private handleStrandIdle;
1185
1512
  /**
@@ -1202,8 +1529,8 @@ export declare class CadreNode implements SAppIdLookup {
1202
1529
  /**
1203
1530
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
1204
1531
  * first: the discovery seed may have grown since the strand last ran. Shared by
1205
- * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
1206
- * both apply the same fresh seed resolution. `resumeStrand` is idempotent
1532
+ * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
1533
+ * apply the same fresh resolution. `resumeStrand` is idempotent
1207
1534
  * (returns the live instance unchanged) as a backstop against double-resume.
1208
1535
  */
1209
1536
  private resumeStrandRuntime;
@@ -1264,6 +1591,14 @@ export declare class CadreNode implements SAppIdLookup {
1264
1591
  * Add a strand with its sApp configuration.
1265
1592
  * The hosting application must provide the sApp schema when creating a strand.
1266
1593
  *
1594
+ * This is the ATTACH half only — it starts the local instance and never publishes the
1595
+ * `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
1596
+ * FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
1597
+ * With no explicit `founder` flag, founder-ness is derived from the row's
1598
+ * `FounderOwnerKey` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
1599
+ * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
1600
+ * anyone else's row joins, without the caller needing to know which it is.
1601
+ *
1267
1602
  * A rejected call leaves nothing running but DOES leave the sApp config
1268
1603
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1269
1604
  * automatic relaunch need it. So once the strand's row is visible on the control
@@ -1278,16 +1613,41 @@ export declare class CadreNode implements SAppIdLookup {
1278
1613
  * owner identity, so other cadre members discover it via control-network
1279
1614
  * sync (their {@link StrandWatcher} fires `strand:discovered`).
1280
1615
  *
1281
- * This is the owner-signed `Strand` INSERT that {@link addStrand}
1282
- * deliberately omits: `addStrand` only starts the LOCAL strand instance,
1283
- * whereas publishing makes the strand visible cadre-wide. A typical creator
1284
- * does both (start locally + publish); a discovering peer only does
1285
- * `addStrand` (the row already exists).
1616
+ * **Founding a strand is TWO steps** — this one (publish the row cadre-wide) and
1617
+ * {@link addStrand} (start the local instance, `founder: true` to write the strand's
1618
+ * `Header`/founding membership). `addStrand` deliberately omits the insert: it only
1619
+ * starts the LOCAL instance, whereas publishing makes the strand visible cadre-wide. A
1620
+ * discovering peer only does `addStrand` (the row already exists). Callers founding a
1621
+ * strand should use {@link foundStrand}, which performs both steps and is safe to
1622
+ * re-run — hand-rolling the pair is what left strands half-founded when an app was
1623
+ * killed between them.
1624
+ *
1625
+ * **Idempotent for identical content.** A live row whose `(Type, MemberPrivateKey)` match
1626
+ * the arguments is the state this call would have produced, so the call logs and returns
1627
+ * that row instead of writing — a publish interrupted after it committed can be repeated.
1628
+ * DIFFERENT content on the same id throws, naming the columns that differ, rather than
1629
+ * surfacing the raw `UNIQUE constraint failed: Strand.Id`: silently accepting it would
1630
+ * let a retry reopen a closed strand or swap the key gating its reads. The read and the
1631
+ * insert are not atomic, so a concurrent founder (two machines of one party, same id) is
1632
+ * caught on the insert's uniqueness rejection and resolved the same way — re-read, no-op
1633
+ * on a match, rethrow otherwise. Never an overwrite either way.
1634
+ *
1635
+ * A tombstoned strand is NOT resurrected here: {@link unpublishStrand} deletes the row, so
1636
+ * the idempotent branch is unreachable and the ordinary publish path re-seats it, exactly
1637
+ * as that method documents.
1638
+ *
1639
+ * **Already stuck on `UNIQUE constraint failed: Strand.Id`?** The row is already published
1640
+ * — attach, do NOT republish: `addStrand` (or {@link foundStrand}) with the id and, for a
1641
+ * closed strand, the `MemberPrivateKey` read back from the row. `unpublishStrand` + a
1642
+ * fresh publish also clears it for an OPEN strand, but is destructive for a closed one
1643
+ * (the key exists nowhere else). No app-data wipe is needed for either.
1286
1644
  *
1287
1645
  * The insert is signed with the ed25519 key behind this node's PeerId — which
1288
1646
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
1289
- * so peer identity and owner key are one and the same. That key must be
1290
- * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
1647
+ * so peer identity and owner key are one and the same. That key is also persisted on the
1648
+ * row as `FounderOwnerKey` (the schema pins the column to the verified signer), which is
1649
+ * what later lets any launch derive "this machine is the founder" from the row alone.
1650
+ * The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
1291
1651
  * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
1292
1652
  * Failing loudly here is intentional: a silently-unpublished strand would run
1293
1653
  * as a local-only island that no peer could ever discover or join.
@@ -1296,10 +1656,62 @@ export declare class CadreNode implements SAppIdLookup {
1296
1656
  * {@link addStrand}).
1297
1657
  * @param type - `'o'` for open (default) or `'c'` for closed.
1298
1658
  * @param memberPrivateKey - Optional membership key for a closed strand.
1299
- * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1300
- * the control DB rejects the (unauthorized) insert.
1301
- */
1302
- publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<void>;
1659
+ * @returns The live `Strand` row — the one just inserted, or the matching one already
1660
+ * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
1661
+ * on a repeat it is the stored key, not the argument.
1662
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, a
1663
+ * row with the same id holds different content, or the control DB rejects the
1664
+ * (unauthorized) insert.
1665
+ */
1666
+ publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<StrandRow>;
1667
+ /**
1668
+ * Found a strand — publish its row cadre-wide AND start the local instance as its
1669
+ * founder — in one resumable call. The single entry point for creating a strand; a
1670
+ * caller joining one someone else founded uses {@link addStrand} alone.
1671
+ *
1672
+ * Safe to re-run from any point of interruption, which hand-rolling
1673
+ * {@link publishStrand} + {@link addStrand} is not. Either half can already have
1674
+ * happened:
1675
+ *
1676
+ * - **row already published** → the stored row is adopted rather than re-published, so a
1677
+ * run killed after the insert committed no longer dies on
1678
+ * `UNIQUE constraint failed: Strand.Id`. For a closed strand the row's STORED
1679
+ * `MemberPrivateKey` wins over `config.memberPrivateKey`: a caller that mints a key per
1680
+ * attempt (as the reference apps do) would otherwise present a key that does not match
1681
+ * the membership already seated in the strand.
1682
+ * - **instance already running** → the tracked instance is returned, and — because the
1683
+ * `Strand` row records its publishing machine (`FounderOwnerKey`) — a founder request
1684
+ * against an instance that was first launched as a joiner now runs the (idempotent,
1685
+ * insert-if-absent) founder bootstrap on it rather than silently skipping it
1686
+ * ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
1687
+ * attached first — the reference RN app's `strand:discovered` handler after a restart,
1688
+ * or this node's own {@link StrandWatcher} poll winning `launchStrand`'s
1689
+ * `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
1690
+ *
1691
+ * Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
1692
+ * resolved row's `FounderOwnerKey` is its own owner key. A fresh publish records this
1693
+ * machine, so the common path founds; a machine that lost a concurrent founding race to
1694
+ * a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
1695
+ * machines bootstrapping the same strand on separate replicas is the double-`Header`
1696
+ * hazard. The returned {@link FoundStrandResult.founded} says which happened.
1697
+ *
1698
+ * `Type` is compared, not adopted: a stored row of the other type means the caller and
1699
+ * the control plane disagree about what this strand IS, so it throws.
1700
+ *
1701
+ * @returns The instance AND the row the strand actually runs under — read the membership
1702
+ * key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
1703
+ * @throws if the node is not started, the id is blank, a published row of the same id has
1704
+ * a different `Type`, or either half rejects.
1705
+ */
1706
+ foundStrand(config: FoundStrandConfig): Promise<FoundStrandResult>;
1707
+ /**
1708
+ * Resume onto an already-published `Strand` row: keep its stored content, reject a type
1709
+ * disagreement. The stored `MemberPrivateKey` is authoritative — see {@link foundStrand}
1710
+ * for why a caller-minted key must lose here (and why that is NOT the same rule as
1711
+ * {@link publishStrand}'s, which throws on a key mismatch because it is being asked to
1712
+ * WRITE that key, not to resume onto what already exists).
1713
+ */
1714
+ private adoptPublishedStrand;
1303
1715
  /**
1304
1716
  * Remove this party's `Strand` row from the shared control database — the owner-signed
1305
1717
  * inverse of {@link publishStrand}.
@@ -1451,14 +1863,25 @@ export declare class CadreNode implements SAppIdLookup {
1451
1863
  * seed, starts the strand, and registers it with the hibernation manager
1452
1864
  * before emitting `strand:started`.
1453
1865
  *
1454
- * Idempotent no-op when the strand manager already tracks `strand.Id` — the
1455
- * ordinary founding sequence is `addStrand` (starts it locally) followed by
1456
- * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
1457
- * rediscovers a row it already started and calls this again via
1458
- * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
1866
+ * Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
1867
+ * one deliberately — their consent-seated rows carry a null `FounderOwnerKey`); when
1868
+ * unset it is DERIVED from the row, so the control-discovered path
1869
+ * (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
1870
+ * without the caller having to know. The derivation is pure key comparison — no I/O.
1871
+ *
1872
+ * Idempotent when the strand manager already tracks `strand.Id` — the watcher
1873
+ * rediscovers a row this node already started and calls this again via
1874
+ * `handleStrandAdded`; without the guard that re-entry would resolve a fresh
1459
1875
  * (already-connected) cohort seed and re-emit `strand:started` for an
1460
1876
  * instance that never stopped. The guard runs before the cohort-seed RPC
1461
- * fan-out, so a rediscovery costs nothing beyond the map lookup.
1877
+ * fan-out, so a rediscovery costs nothing beyond the map lookup plus the (pure)
1878
+ * founder derivation. NOT a silent no-op any more when the launch resolves as a
1879
+ * FOUNDER: the tracked instance may have been launched first as a joiner (an app
1880
+ * attach, or this node's own watcher poll winning the `resolveCohortSeed` window
1881
+ * below), which used to drop the founder request and leave the strand headerless —
1882
+ * now the tracked instance is founded in place
1883
+ * ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
1884
+ * so the bootstrap actually runs before this resolves.
1462
1885
  */
1463
1886
  private launchStrand;
1464
1887
  /**
@@ -1488,6 +1911,13 @@ export declare class CadreNode implements SAppIdLookup {
1488
1911
  * for a relay we are not yet connected to.
1489
1912
  */
1490
1913
  private resolveCohortSeed;
1914
+ /**
1915
+ * The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
1916
+ * from CONNECTED cohort siblings over the control-mesh strand-addr RPC, doubling as
1917
+ * the delegate announcement when `delegatePeerId` is given. Empty when the control DB
1918
+ * or node is absent (not yet started / torn down).
1919
+ */
1920
+ private resolveSiblingSeed;
1491
1921
  /**
1492
1922
  * The co-cadre siblings worth sending a strand-addr RPC to right now: cohort
1493
1923
  * members (self excluded) we already hold an open control connection to. They
@@ -1521,11 +1951,25 @@ export declare class CadreNode implements SAppIdLookup {
1521
1951
  private connectedSiblingTargets;
1522
1952
  /**
1523
1953
  * The circuit relays this node's strand nodes would reserve through: the
1524
- * union of the RESOLVED listen addrs' circuits — a hand-written
1525
- * `network.listenAddrs` circuit entry or a `network.relayAddrs` entry, which
1526
- * the strand node inherits verbatim either way — and the control node's own
1527
- * live `/p2p-circuit` multiaddrs (a reservation this node discovered rather
1528
- * than configured).
1954
+ * union of CONFIGURED relays — `network.relayAddrs`, plus any hand-written
1955
+ * `network.listenAddrs` circuit entry, both of which a strand node inherits
1956
+ * verbatim — and the control node's own live `/p2p-circuit` multiaddrs (a
1957
+ * reservation this node discovered rather than configured).
1958
+ *
1959
+ * The CONFIGURED half reads `network.relayAddrs` and `network.listenAddrs`
1960
+ * directly rather than going through {@link resolveListenAddrs}: the control
1961
+ * node's resolution takes the `'search'` route, whose bare `/p2p-circuit` entry
1962
+ * names no relay, so a configured relay would be unannounceable here until this
1963
+ * node's own reservation landed — and the announce must be in place BEFORE a
1964
+ * strand node dials its reservation, since the relay's membership gate does not
1965
+ * know the strand's derived peerId (see delegate-admission.ts). Configuration is
1966
+ * authoritative; the live multiaddrs only ADD relays nobody configured.
1967
+ *
1968
+ * The `listenAddrs` half is now unreachable on a control node — `relay-addrs.ts`
1969
+ * rejects a hand-written `<relay>/p2p-circuit` listen entry outright, because the
1970
+ * bring-up quiet period denies the dial that listener makes from inside
1971
+ * `libp2p.start()`. Kept because it costs one spread and this is the one place
1972
+ * that would silently stop announcing if that rejection is ever relaxed.
1529
1973
  *
1530
1974
  * NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
1531
1975
  * source) gets no announcement, and a membership-gated one will deny it; fine
@@ -1587,9 +2031,18 @@ export declare class CadreNode implements SAppIdLookup {
1587
2031
  */
1588
2032
  private refreshStrandPeerAddrs;
1589
2033
  /**
1590
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, then
1591
- * merge their answers into this strand's address book. Errors are logged and
1592
- * swallowed so one strand's failure never costs the others their refresh.
2034
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
2035
+ * answers with this strand's cross-party contact addresses, and merge the lot into the
2036
+ * strand's address book. Errors are logged and swallowed so one strand's failure never
2037
+ * costs the others their refresh.
2038
+ *
2039
+ * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
2040
+ * The cross-party addresses still have to be re-merged in that case: they were learned
2041
+ * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
2042
+ * thing standing between them and the peerStore's one-hour expiry. A strand with
2043
+ * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
2044
+ * unstamped, so the next reconcile tick retries rather than sitting out the whole
2045
+ * refresh interval having done nothing.
1593
2046
  */
1594
2047
  private refreshOneStrandPeerAddrs;
1595
2048
  /**
@@ -1610,6 +2063,15 @@ export declare class CadreNode implements SAppIdLookup {
1610
2063
  * handshake, and a bad entry costs one failed dial that ages out at the
1611
2064
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
1612
2065
  * `backlog/strand-network-nat-relay-reachability`.
2066
+ *
2067
+ * NOTE: one input does NOT age out — the cross-party addresses a formation
2068
+ * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
2069
+ * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
2070
+ * also means a junk entry from a responder survives for the node's lifetime rather
2071
+ * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
2072
+ * node deliberately formed a strand with, and still authority-free — so the
2073
+ * exposure is a handful of failed dials, not a trust hole. Evict on
2074
+ * `unpublishStrand` if that ever stops being an acceptable price.
1613
2075
  */
1614
2076
  private mergeStrandPeerAddrs;
1615
2077
  /**
@@ -1719,16 +2181,48 @@ export declare class CadreNode implements SAppIdLookup {
1719
2181
  * Get the control network node (for advanced use)
1720
2182
  */
1721
2183
  getControlNode(): Libp2p | null;
2184
+ /**
2185
+ * The boot-path half of {@link reserveRelays}: reserve through every
2186
+ * `network.relayAddrs` entry, or fail `start()`.
2187
+ *
2188
+ * WHY IT RUNS HERE, AT THE END. `network.relayAddrs` used to resolve to a
2189
+ * CONFIGURED circuit listener, which libp2p dials from inside `libp2p.start()`
2190
+ * — so the relay was already a connected peer, and therefore already in this
2191
+ * node's Optimystic cohort, before `ControlDatabase.initialize()` ran. Building
2192
+ * that database is a long chain of cohort-consulting block probes (catalog
2193
+ * hydration, then one per table, then the indexes), and a sibling that has not
2194
+ * yet replicated this node's `CadrePeer` row correctly refuses every one of
2195
+ * them, so bring-up died on `BlockUnavailableError` — deterministically, on
2196
+ * every boot of a relay-only node. Retrying could not converge either: the
2197
+ * condition that clears the refusal is this node's own row reaching the
2198
+ * sibling, and writing that row needs the database the retry is building.
2199
+ *
2200
+ * So the invariant is ordering, not retrying: the control database is built
2201
+ * while this node holds ZERO control connections (a cohort of one, entirely
2202
+ * local), and only then does it reach out. Everything above in {@link start}
2203
+ * has completed by the time this runs.
2204
+ *
2205
+ * BUDGET: the supervisor's first attempt — `relay-reservation.ts`'s
2206
+ * `DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s — is what `start()` waits on — deliberately the module default rather
2207
+ * than a boot-specific one. A healthy dial-plus-reserve is sub-second even over
2208
+ * a WAN, so 10 s is slack for a slow link; going much longer would make a dead
2209
+ * relay indistinguishable from a hung start, and much shorter would fail nodes
2210
+ * on links that were merely slow. The retries carry on in the background after
2211
+ * this resolves, exactly as they do for a {@link reserveRelays} caller.
2212
+ */
2213
+ private driveControlRelayReservation;
1722
2214
  /**
1723
2215
  * Start keeping a relay reservation: dial the given relay(s) from the control
1724
2216
  * node, ask the first one that answers for a reservation slot, wait until the
1725
2217
  * resulting `/p2p-circuit` address makes this node dialable — and keep a
1726
2218
  * supervisor running that re-drives whenever the reservation is later lost.
1727
2219
  *
1728
- * For nodes that listen on the bare `/p2p-circuit` SEARCH addr (a browser tab)
1729
- * rather than a configured `network.relayAddrs` circuit listener — see
1730
- * `relay-reservation.ts` for why the two are alternatives and why configuring
1731
- * both is a regression. Opt-in: nothing calls this for the CLI/host nodes.
2220
+ * The FAIL-SOFT entry point, for a caller that discovers its relay at runtime
2221
+ * (a browser tab, a host UI). A node that names `network.relayAddrs` gets the
2222
+ * same drive from {@link driveControlRelayReservation} at the end of
2223
+ * {@link start}, which is fail-fast instead. Both fill the pending reservation
2224
+ * the bare `/p2p-circuit` search listener registered; calling this afterwards
2225
+ * simply replaces the supervisor with one over the new list.
1732
2226
  *
1733
2227
  * Resolves once the FIRST attempt has settled, exactly as it did when it drove
1734
2228
  * once; the retries continue in the background until {@link stop} or the next
@@ -1790,13 +2284,40 @@ export declare class CadreNode implements SAppIdLookup {
1790
2284
  * Push the multiaddrs that future invites should advertise. Pass `null` to
1791
2285
  * revert to the libp2p-reported addresses (the default). The host calls this
1792
2286
  * at spawn and on every NAT change.
2287
+ *
2288
+ * Entries need NOT carry a `/p2p/<peerId>` suffix — {@link resolveInviteAddresses}
2289
+ * appends this node's own before anything publishes or dials them (see
2290
+ * {@link normalizeSelfAddrs}). Passing them suffixed is equally fine.
1793
2291
  */
1794
2292
  setInviteAddresses(addresses: string[] | null): void;
1795
2293
  /**
1796
2294
  * Resolve the addresses to embed in invites. Prefers pushed addresses, then
1797
- * any config-supplied resolver, then the libp2p-observed multiaddrs.
2295
+ * any config-supplied resolver, then the libp2p-observed multiaddrs. The two
2296
+ * app-supplied sources are normalized onto `/p2p/<self>` (see
2297
+ * {@link normalizeSelfAddrs}) so neither hook has to remember the suffix;
2298
+ * libp2p's own addresses already carry it.
1798
2299
  */
1799
2300
  private resolveInviteAddresses;
2301
+ /**
2302
+ * Normalize addresses an app handed us for THIS node onto `/p2p/<self>`, the
2303
+ * same invariant {@link normalizeDialAddrs} enforces on the way out.
2304
+ *
2305
+ * `setInviteAddresses` (the admin API `PUT /admin/invite-addresses`) and
2306
+ * `network.inviteAddressResolver` both take arbitrary strings, and whatever
2307
+ * they return ends up in this node's published `CadrePeer` record — so ONE
2308
+ * unsuffixed entry from either hook is enough to give every sibling in the
2309
+ * party a mixed candidate list for this peer. Establishing the suffix here,
2310
+ * where those strings enter the system, means neither hook has to know the
2311
+ * rule; `cadre-host`'s `buildInviteAddresses` already appends it, and
2312
+ * re-normalizing an already-suffixed address is a no-op.
2313
+ *
2314
+ * Unparsable and other-peer-addressed entries are passed through untouched
2315
+ * rather than dropped: publication is not the place to police an address's
2316
+ * validity, and {@link resolvePeerAddrs} already drops both on the read side.
2317
+ * Before `start()` resolves an identity there is no peer id to append, so the
2318
+ * list is returned as given.
2319
+ */
2320
+ private normalizeSelfAddrs;
1800
2321
  /**
1801
2322
  * Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
1802
2323
  * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
@@ -1853,8 +2374,13 @@ export declare class CadreNode implements SAppIdLookup {
1853
2374
  * legit member goes un-authorized on readers that only pin the new key. Full
1854
2375
  * rotation handling (re-vouch on rotate) is the
1855
2376
  * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
2377
+ *
2378
+ * @param retry - Whether the underlying membership read may retry a transient cluster
2379
+ * failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
2380
+ * as the control database's membership listener with that database's write lock held;
2381
+ * see its comment.
1856
2382
  */
1857
- listAuthorizedMembers(): Promise<Array<{
2383
+ listAuthorizedMembers(retry?: boolean): Promise<Array<{
1858
2384
  peerId: string;
1859
2385
  multiaddr: string | null;
1860
2386
  }>>;
@@ -1940,6 +2466,23 @@ export declare class CadreNode implements SAppIdLookup {
1940
2466
  * best-effort (a failed read keeps the previous snapshot); never rejects.
1941
2467
  */
1942
2468
  refreshMembershipGate(reason?: string): Promise<void>;
2469
+ /**
2470
+ * Re-materialize a CLOSED strand's revoked-peer deny set now, and hang up any
2471
+ * connected peer it newly covers — the strand-side counterpart to
2472
+ * {@link refreshMembershipGate}.
2473
+ *
2474
+ * Unlike that one, NOTHING calls this automatically: strand membership is
2475
+ * written through the strand's own `Database` handle
2476
+ * (`strand-membership-writer.ts`), which raises no notification this runtime
2477
+ * can hook, and a revocation that arrives by REPLICATION raises none either.
2478
+ * The gate therefore polls (default 30 s). An app that has just called
2479
+ * `revokeMember` or `leaveStrand` should follow the write with this call so
2480
+ * the cut is immediate rather than up to one poll interval late.
2481
+ *
2482
+ * Quiet no-op for a strand that is not running, is quiesced, is open, or has
2483
+ * the gate disabled. Never rejects; resolves once the sweep has finished.
2484
+ */
2485
+ refreshRevocationEnforcement(strandId: string): Promise<void>;
1943
2486
  /**
1944
2487
  * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
1945
2488
  * until the stale flag stays clear, then release the slot.
@@ -2086,6 +2629,17 @@ export declare class CadreNode implements SAppIdLookup {
2086
2629
  * @returns The member key and strand info if successful
2087
2630
  */
2088
2631
  formStrand(invitation: OpenInvitation, disclosure?: StrandFormationDisclosure): Promise<FormStrandResult>;
2632
+ /**
2633
+ * Remember the responder's strand-network addresses for a strand this node just
2634
+ * formed, so the strand's discovery seed has something to work with when the app
2635
+ * launches it (see {@link crossPartyStrandAddrs}).
2636
+ *
2637
+ * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
2638
+ * party and must never seed another strand's mesh or the control peerStore. An empty
2639
+ * list is recorded as no entry at all rather than an empty one, so a later formation
2640
+ * against the same strand that DOES disclose addresses is not shadowed.
2641
+ */
2642
+ private recordCrossPartyStrandAddrs;
2089
2643
  /**
2090
2644
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).
2091
2645
  */