@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -1,10 +1,12 @@
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, StrandMembershipInvite, 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';
9
+ import { type ControlCohortReconcileResult } from './control-cohort.js';
8
10
  import { EnrollmentService } from './enrollment.js';
9
11
  import { ControlDatabase } from './control-database.js';
10
12
  import { SeedBootstrapService } from './seed-bootstrap.js';
@@ -57,6 +59,14 @@ export declare class CadreNode implements SAppIdLookup {
57
59
  private trustedOwnerStore;
58
60
  private controlNode;
59
61
  private controlDatabase;
62
+ /**
63
+ * The CONTROL network's peer-join block catch-up — same module as the
64
+ * per-strand ones, but membership-gated (see {@link startControlBackfill}).
65
+ * Created in {@link start} after the control node and database are up, stopped
66
+ * and dropped in {@link cleanup} before either is torn down, so a stop()→
67
+ * start() cycle rebuilds it with a fresh caught-up-peer memo.
68
+ */
69
+ private controlBackfill;
60
70
  private strandWatcher;
61
71
  private strandManager;
62
72
  private hibernationManager;
@@ -82,10 +92,11 @@ export declare class CadreNode implements SAppIdLookup {
82
92
  private _running;
83
93
  /**
84
94
  * The control database's own raw storage (cache-wrapped), resolved once per
85
- * `start()` and released in {@link cleanup}. Owning it is what keeps
86
- * `config.storage.provider('control')` a once-per-runtime call: a `stop()` then
87
- * `start()` cycle on this object re-resolves against a live cache, and never
88
- * orphans the previous wrapper's registration in the shared cache pool.
95
+ * `start()` and released in {@link cleanup}. Owning it is what keeps the
96
+ * provider call for the control scope — `controlStorageScope(partyId)`, see
97
+ * `storage-scope.ts` — a once-per-runtime call: a `stop()` then `start()` cycle
98
+ * on this object re-resolves against a live cache, and never orphans the
99
+ * previous wrapper's registration in the shared cache pool.
89
100
  */
90
101
  private controlStorage;
91
102
  /**
@@ -141,6 +152,14 @@ export declare class CadreNode implements SAppIdLookup {
141
152
  private controlBringUpInFlight;
142
153
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
143
154
  private sAppConfigs;
155
+ /**
156
+ * Strands the control network advertises that no local sAppConfig claims — the
157
+ * backlog behind `strand:discovered`. Maintained in {@link handleStrandAdded}
158
+ * (added), {@link addStrand} (claimed), {@link detachStrand} (stopped or its
159
+ * control row vanished) and {@link cleanup}. Read through
160
+ * {@link getDiscoveredStrands}.
161
+ */
162
+ private discoveredStrands;
144
163
  /**
145
164
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
146
165
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
@@ -238,39 +257,149 @@ export declare class CadreNode implements SAppIdLookup {
238
257
  */
239
258
  private readonly strandPeerAddrRefreshAt;
240
259
  /**
241
- * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
242
- * this node has applied, keyed by peer id, valued by the seed's multiaddr
243
- * strings (parsed lazily, at dial time). Written by
244
- * {@link recordSeedBootstrapPeers}; read only by
245
- * {@link dialColdStartBootstrap} while the control database still has no
246
- * siblings to dial.
260
+ * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
261
+ * responder's live strand addrs, carried back on the formation result
262
+ * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
263
+ *
264
+ * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
265
+ * resolves a strand's addresses is membership-gated and answers own-party siblings
266
+ * only, so without this map a joiner's cohort seed for a two-party strand is empty and
267
+ * the mesh never forms.
268
+ *
269
+ * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
270
+ * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
271
+ * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
272
+ * them.
273
+ *
274
+ * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
275
+ * the process, and a restarted initiator with no sibling running the strand is back to
276
+ * an empty seed until the durability work lands
277
+ * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
278
+ * so a responder whose relay reservation rotates before the initiator dials leaves a
279
+ * dead entry behind; recovery today is a fresh invitation.
280
+ *
281
+ * NOTE: entries are kept for the node's lifetime, including across a
282
+ * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
283
+ * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
284
+ * number of KEYS is bounded only by the strands this node has ever formed as an
285
+ * initiator (not by time); if a node ever forms strands at scale, evict on
286
+ * `unpublishStrand` or cap the map.
287
+ */
288
+ private readonly crossPartyStrandAddrs;
289
+ /**
290
+ * PENDING strand membership invitations learned at formation, keyed by strandId — the
291
+ * single-use `Strand.Invite` credential a closed-strand formation result carried back
292
+ * ({@link FormStrandResult.membershipInvite}), waiting for this node's strand bring-up
293
+ * to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
294
+ * — the `strand-node-binds-member-peer` half of the party-identity chain).
295
+ *
296
+ * IN-MEMORY, deliberately, like {@link crossPartyStrandAddrs}: a restarted joiner
297
+ * re-forms from scratch anyway (durability is
298
+ * `backlog/feat-cross-party-strand-addr-durability`'s problem), and re-forming issues
299
+ * a fresh invitation. A re-formation against the same strand REPLACES the entry — the
300
+ * fresh invitation supersedes one that may have expired. The party key the invitation
301
+ * admits, by contrast, IS persisted (`StrandPartyKey`, seated by
302
+ * {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
303
+ * invitation never orphans an identity.
304
+ *
305
+ * INVALIDATION is owned by the strand's bring-up membership reconciler
306
+ * (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
307
+ * in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
308
+ * against an already-seated member, or found dead (expired / cancelled / consumed
309
+ * elsewhere / the strand sealed). Until the strand is actually launched here the entry
310
+ * just waits.
311
+ *
312
+ * NOTE: entries live for the node's lifetime (one small pair per formed closed
313
+ * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
314
+ * if a node ever forms strands at scale, evict alongside that map.
315
+ */
316
+ private readonly pendingMembershipInvites;
317
+ /**
318
+ * Founder launches refused because the strand was founded before the per-party
319
+ * identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
320
+ * {@link issueStrandMembershipInvite}, which rethrows the recorded error so a bound
321
+ * redemption against the strand is rejected as "must be recreated" — not told to retry
322
+ * because the runtime is not live (a refused fresh launch leaves none) or failed by the
323
+ * `Strand.Invite` gate (a refused in-place founding leaves a joiner instance up whose
324
+ * party key is no manager).
325
+ *
326
+ * Written and cleared by {@link launchStrand}; also cleared by {@link detachStrand},
327
+ * {@link unpublishStrand} and {@link cleanup}, so a recreated id starts clean.
328
+ * In-memory: after a restart the next founder launch of the strand records it again;
329
+ * until then issuance still reads the fingerprint off the live rows whenever a runtime
330
+ * is up — only a strand with no runtime at all answers retryably meanwhile.
331
+ */
332
+ private readonly strandLaunchRefusals;
333
+ /**
334
+ * Dial targets learned out of band, keyed by peer id, valued by the multiaddr
335
+ * strings this node was handed (parsed lazily, at dial time). Two writers:
336
+ * {@link recordSeedBootstrapPeers} retains the owner-flagged peers of every seed
337
+ * this node applies, and {@link addDrone} retains the addresses of every node
338
+ * this node adds. Two readers: {@link dialColdStartBootstrap} dials every entry
339
+ * while the control database still has no siblings, and
340
+ * {@link resolveControlDialAddrs} falls back to a sibling's entry when neither
341
+ * its signed record nor the address book yields an address.
247
342
  *
248
343
  * A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
249
344
  * targets must outlive the process: a node seeded into a party it could not
250
345
  * reach has nothing else on disk naming that party's addresses (`applySeed`
251
346
  * writes no control row, and `CadrePeer` fills in only after a connection
252
347
  * succeeds), so an in-memory-only set left it stranded permanently across a
253
- * restart. Constructed (or adopted from `config.bootstrapPeers.store`) by
348
+ * restart. An owner that added a node it cannot be dialed by is in the same
349
+ * position: the added node's row stays unsigned, so unresolvable, until that
350
+ * node self-publishes over a connection only the owner can open. Constructed (or
351
+ * adopted from `config.bootstrapPeers.store`) by
254
352
  * {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
255
353
  * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
256
354
  * node instance — same lifecycle as {@link trustedOwnerStore}. Durability
257
- * depends on the injected backend: Node gets a file-backed store from the CLI,
258
- * RN/browser stay ephemeral for now.
355
+ * depends on the injected backend; the default is in-memory.
259
356
  *
260
357
  * Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
261
358
  * reads for the steady-state path. The peer store is shared with everything
262
359
  * libp2p discovers, so "dial every entry" would grow into dialing arbitrary
263
360
  * discovered peers as the node lives longer; this store holds exactly the peers
264
- * an owner-signed, trust-anchored seed nominated as owners. `applySeed` merges
265
- * the same addresses into the peer store as well, so the two never disagree —
266
- * this one is just the precisely-scoped subset.
361
+ * an owner-signed, trust-anchored seed nominated as owners, and the nodes this
362
+ * node chose to add. `applySeed` also merges a seed's addresses into the peer
363
+ * store, where they age out; an added node's addresses are never merged there,
364
+ * because only verified addresses go into the address book (see
365
+ * {@link warmSiblingAddrBook}).
267
366
  *
268
- * A later seed OVERWRITES an entry rather than merging, so a re-seed after an
367
+ * A later record OVERWRITES an entry rather than merging, so a re-seed after an
269
368
  * owner's address changes replaces the stale address instead of accumulating
270
- * dead ones. Entries are never evicted: they are the node's only way back into
271
- * the party if it is ever stranded again.
369
+ * dead ones, and {@link warmSiblingAddrBook} replaces an entry with the addresses
370
+ * the sibling's signed record resolves to when they differ. Only this node's own
371
+ * {@link removePeer} evicts an entry: otherwise they are the node's only way back
372
+ * to those peers if it is ever stranded again.
272
373
  */
273
374
  private bootstrapPeerStore;
375
+ /**
376
+ * Node-local record of the party's enrolled-machine count (see
377
+ * `enrolled-machine-store.ts`), kept so the CONTROL node can declare a
378
+ * block-repair yardstick at bring-up. Constructed (or adopted from
379
+ * `config.enrolledMachines.store`) by {@link initializeEnrolledMachineStore};
380
+ * written by {@link refreshAuthorizedControlPeers}. Like
381
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup},
382
+ * so it survives a stop()→start() cycle of the same node instance.
383
+ */
384
+ private enrolledMachineStore;
385
+ /**
386
+ * The enrolled-machine count this node's CONTROL libp2p node was (or will be)
387
+ * built with — read out of {@link enrolledMachineStore} during {@link start},
388
+ * before {@link createControlNode}, and consumed by
389
+ * {@link buildControlNodeOptions}.
390
+ *
391
+ * A captured FIELD rather than a live `store.count()` read, deliberately: the
392
+ * store moves as membership changes, but Optimystic froze the policy when the
393
+ * node was built, so this field is the honest answer to "what did this node
394
+ * actually declare?" and does not drift away from the running node. It is
395
+ * re-read on each {@link start}, which is what makes a stop()→start() cycle pick
396
+ * up a count recorded during the previous run.
397
+ *
398
+ * `undefined` — a brand-new node, an unreadable slot, or the ephemeral default
399
+ * store — means "this node does not know", which `controlClusterPolicy` answers
400
+ * with the frozen base policy itself.
401
+ */
402
+ private declaredEnrolledMachines;
274
403
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
275
404
  private selfRegistrationTimer;
276
405
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -298,6 +427,19 @@ export declare class CadreNode implements SAppIdLookup {
298
427
  * both read "no row yet" and race a duplicate INSERT (a `CadrePeer` PK conflict).
299
428
  */
300
429
  private registerSelfInFlight;
430
+ /**
431
+ * When this node last PUBLISHED its own `CadrePeer` address record (inserted or
432
+ * refreshed), or null if it never has in this session. Stamped by
433
+ * {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
434
+ * from — the boot pass, the heartbeat, an address change, a drain or an explicit call.
435
+ *
436
+ * Read by {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
437
+ * attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
438
+ * machine in the party is already discarding this node's address as stale.
439
+ */
440
+ private lastSelfRecordPublishAt;
441
+ /** Say-once latch for {@link escalateIfSelfRecordStale}; re-armed by the next publish. */
442
+ private selfRecordStaleWarned;
301
443
  /**
302
444
  * Re-replication queue for owner `authorizePeer` writes that committed while
303
445
  * this node was alone — `controlNode.getConnections().length === 0` at write
@@ -331,6 +473,14 @@ export declare class CadreNode implements SAppIdLookup {
331
473
  * exactly a "removals from before this lifetime" case).
332
474
  */
333
475
  private reissuedHeldRevocations;
476
+ /**
477
+ * Whether this process has seen the singleton `Revocation` ledger marker filed — its own
478
+ * `'opened'` or an `'already-open'` answer, in {@link openRevocationLedgerIfDue}. A
479
+ * record of work done, not a cached authorization answer: the marker can never be
480
+ * deleted (`NoDelete`), so this can only go stale if the database itself is replaced,
481
+ * which is why stop() clears it with the other per-process replication state.
482
+ */
483
+ private revocationLedgerOpened;
334
484
  /** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
335
485
  private pendingSelfPeerWrite;
336
486
  /** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
@@ -421,6 +571,23 @@ export declare class CadreNode implements SAppIdLookup {
421
571
  * Get all strand instances
422
572
  */
423
573
  getStrands(): Map<string, StrandInstance>;
574
+ /**
575
+ * Strands the control network advertises that no local sAppConfig claims —
576
+ * the backlog behind `strand:discovered`.
577
+ *
578
+ * The event fires once per strand, and it can fire before the app has
579
+ * subscribed (the watcher's first poll runs inside `start()`). So an app that
580
+ * auto-joins discovered strands must subscribe FIRST and then drain this map,
581
+ * not rely on the event alone. Entries leave the map when the strand is
582
+ * claimed ({@link addStrand}) or its control row disappears.
583
+ *
584
+ * Bounded by the number of strands this party has that this node does not run
585
+ * — the control database holds only this party's rows — so there is no cap and
586
+ * no eviction policy to reason about.
587
+ *
588
+ * Returns a snapshot; mutating it does not affect the node.
589
+ */
590
+ getDiscoveredStrands(): Map<string, StrandRow>;
424
591
  /**
425
592
  * Get a specific strand instance
426
593
  */
@@ -503,10 +670,36 @@ export declare class CadreNode implements SAppIdLookup {
503
670
  */
504
671
  private initializeBootstrapPeerStore;
505
672
  /**
506
- * The node-local cold-start bootstrap-peer store (null before {@link start}) —
507
- * the retained owner addresses {@link reconcileControlCohort}'s cold-start
508
- * branch re-dials. Exposed for diagnostics and for a host that wants to show
509
- * "what would this node dial if it is stranded?".
673
+ * Construct (or adopt) the node-local enrolled-machine store and capture the
674
+ * count this run will declare into {@link declaredEnrolledMachines}.
675
+ *
676
+ * Synchronous for the same reason {@link initializeBootstrapPeerStore} is: an
677
+ * injected store has already loaded its persisted count by the time it is handed
678
+ * in (its `open` is the async part), and the in-memory default has nothing to
679
+ * load. Must run BEFORE {@link createControlNode} — that is the whole point of
680
+ * the record; see `enrolled-machine-store.ts`.
681
+ *
682
+ * The store instance is kept across stop()→start() (so a count recorded during
683
+ * the previous run is not lost with the ephemeral default), but the DECLARED
684
+ * count is re-read every time — which is how a restart applies a number the
685
+ * previous run learned.
686
+ *
687
+ * A store scoped to a different party is a configuration error, fail closed. It
688
+ * is only a repair hint, but a hint sized by a foreign party's membership is a
689
+ * number nobody chose, and the mismatch always means a miswired embedder.
690
+ */
691
+ private initializeEnrolledMachineStore;
692
+ /**
693
+ * The node-local enrolled-machine store (null before {@link start}) — what this
694
+ * node last knew about its party's size. Exposed for diagnostics and for a host
695
+ * that wants to show which repair yardstick the next launch will declare.
696
+ */
697
+ getEnrolledMachineStore(): EnrolledMachineStore | null;
698
+ /**
699
+ * The node-local bootstrap-peer store (null before {@link start}) — the dial
700
+ * targets learned out of band that {@link reconcileControlCohort} falls back to.
701
+ * Exposed for diagnostics and for a host that wants to show "what would this
702
+ * node dial if it is stranded?".
510
703
  */
511
704
  getBootstrapPeerStore(): BootstrapPeerStore | null;
512
705
  /**
@@ -534,6 +727,44 @@ export declare class CadreNode implements SAppIdLookup {
534
727
  */
535
728
  trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void>;
536
729
  private createControlNode;
730
+ /**
731
+ * The control libp2p node's Optimystic network name — ONE binding for every
732
+ * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
733
+ * `/optimystic/<networkName>/...`), so the node options and the block-transfer
734
+ * protocol prefix the control backfill dials can never drift apart.
735
+ */
736
+ private controlNetworkName;
737
+ /**
738
+ * Arm the CONTROL network's peer-join block catch-up: push every block in the
739
+ * control database's own raw store to each newly connected AUTHORIZED member
740
+ * this runtime has not yet caught up. This is what physically replicates
741
+ * control blocks committed while the writer was alone — the named
742
+ * collection-header blocks written once at genesis above all, whose revision
743
+ * never moves again, so no later commit ever carries them to a member that
744
+ * joined after them. Without it, such a member that restarts offline reads
745
+ * the affected control tables as EMPTY, silently (`isMember()` answers false
746
+ * for peers it knew about before the restart).
747
+ *
748
+ * Unlike the per-strand instances (see `strand-instance-manager.ts`), pushes
749
+ * are gated on {@link isAuthorizedMember}, judged at push time: the control
750
+ * network's inbound connection gate deliberately admits non-members in
751
+ * several states (seed delivery to an un-enrolled node, an open enrollment
752
+ * window, an outstanding invitation, configured bootstrap/relay peers), and
753
+ * pushing the whole control store to such a peer would hand a stranger the
754
+ * party's entire membership, addresses and strand list. The receiving side
755
+ * needs no work of its own: `createLibp2pNode` registers the block-transfer
756
+ * handler on every node it builds, control node included, and this node's
757
+ * per-stream gate (`authorizeInboundControlStream`) covers the inbound
758
+ * direction. A denied peer is retried on its next `connection:open`, and —
759
+ * because the production join order is connect-then-authorize — on every
760
+ * committed membership change via {@link refreshAuthorizedControlPeers}'s
761
+ * `scheduleConnectedPeers()` call.
762
+ *
763
+ * No-ops (logged) when the embedder configured no control storage or the
764
+ * node exposes no key network — the backfill would have nothing to read or
765
+ * no way to dial.
766
+ */
767
+ private startControlBackfill;
537
768
  /**
538
769
  * The control database's cache-wrapped raw storage, resolved from
539
770
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -564,9 +795,8 @@ export declare class CadreNode implements SAppIdLookup {
564
795
  * operator whose relay slot is decorative may genuinely want only the announced
565
796
  * address, so this reports and proceeds.
566
797
  *
567
- * Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
568
- * hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
569
- * longer route — is caught too.
798
+ * Keyed off `relayAddrs` AND `listenAddrs`, so a hand-written `/p2p-circuit` entry
799
+ * in `listenAddrs` — a reservation by the longer route — is caught too.
570
800
  *
571
801
  * NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
572
802
  * diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
@@ -841,6 +1071,55 @@ export declare class CadreNode implements SAppIdLookup {
841
1071
  registerSelf(): Promise<SelfRegistrationOutcome>;
842
1072
  /** The body of {@link registerSelf}; serialised by its single-flight guard. */
843
1073
  private publishSelfRecord;
1074
+ /**
1075
+ * Record that this node's own address record just landed, and re-arm the stale-record
1076
+ * warning. Called from BOTH publishing paths of {@link publishSelfRecord} and nowhere
1077
+ * else — a `skipped` publish wrote nothing, so it must not refresh the stamp.
1078
+ */
1079
+ private noteSelfRecordPublished;
1080
+ /**
1081
+ * Report an abandoned control write to the embedding app.
1082
+ *
1083
+ * Every control write funnels through `ControlDatabase.lockedWithRetry`, which now tells
1084
+ * this node when it gives one up. Foreground writes ALSO reject to their caller; the
1085
+ * background ones ({@link startRecordRefresh}'s republish and the two replication
1086
+ * drains) are fired unawaited with a `debug`-only catch, so this event is the only thing
1087
+ * that reaches an app at all.
1088
+ *
1089
+ * Reporting only — the one operator-visible escalation lives on the republish path
1090
+ * ({@link escalateIfSelfRecordStale}), where the consequence is measurable.
1091
+ */
1092
+ private noteControlWriteAbandoned;
1093
+ /**
1094
+ * Escalate ONE failed self-address republish to the operator — once — when the node has
1095
+ * not published its own record for longer than a record stays fresh.
1096
+ *
1097
+ * The bar is the CONSEQUENCE, not a failure count: a resolver discards a `CadrePeer`
1098
+ * record older than {@link DEFAULT_PEER_RECORD_MAX_AGE_MS} (15 minutes) and the heartbeat
1099
+ * re-stamps at half that, so by the time this gap opens every other machine in the party
1100
+ * has already stopped accepting this node's address. A count would fire far too early —
1101
+ * one failed heartbeat is already half the budget, and a single miss costs nothing.
1102
+ *
1103
+ * Say-once, re-armed by the next successful publish ({@link noteSelfRecordPublished}), so
1104
+ * a node that stays broken warns once rather than every 7.5 minutes.
1105
+ *
1106
+ * A node that has never published in this session is skipped: it has no record out there
1107
+ * to go stale, and the reason it has none (not a member yet, no signing key, revoked) is
1108
+ * already logged by `registerSelf` itself.
1109
+ *
1110
+ * Only a FAILED republish is checked. A heartbeat whose publish reports `skipped` neither
1111
+ * stamps nor warns, so a node that stops being able to publish without erroring — revoked,
1112
+ * or its row removed — goes quiet here. That is the intended reading: a revoked node has
1113
+ * nothing to publish and its unreachability is the point, not a fault to report.
1114
+ *
1115
+ * NOTE: the second `console.*` in this library, and the reason
1116
+ * {@link warnIfAnnounceAddrsDiscardRelay}'s note says a third should not simply be added:
1117
+ * both are operator warnings about a configuration/health condition an embedder cannot
1118
+ * see otherwise, and both have an event beside them (`control:write-abandoned` carries
1119
+ * the underlying write failure). A third such condition should surface through
1120
+ * {@link CadreNodeEvents} alone unless it likewise degrades the whole party in silence.
1121
+ */
1122
+ private escalateIfSelfRecordStale;
844
1123
  /**
845
1124
  * Sign this node's address record for publication, stamped strictly later than
846
1125
  * `existing` (the row it is about to replace, or null for a first insert) so a
@@ -857,6 +1136,24 @@ export declare class CadreNode implements SAppIdLookup {
857
1136
  * producing an unresolvable row.
858
1137
  */
859
1138
  private getSelfSigningKey;
1139
+ /**
1140
+ * Does the `Strand` row name THIS machine as its founder — i.e. is its
1141
+ * `FounderOwnerKey` this node's own owner key (the key behind its PeerId)?
1142
+ * A null/absent column (a consent-seated strand, or a hand-built row) and a
1143
+ * node with no owner key both derive `false`: without a positive match this
1144
+ * machine must attach, never bootstrap. Pure key derivation, no I/O — cheap
1145
+ * enough for {@link launchStrand}'s tracked-instance early return.
1146
+ *
1147
+ * NOTE: reads "one owner key per machine" — the reference model, where the owner
1148
+ * key IS the key behind the PeerId. Two machines running the SAME identity key
1149
+ * would both derive `true` and each bootstrap on its own replica, which is the
1150
+ * double-`Header` hazard this derivation exists to avoid. Unreachable today: that
1151
+ * configuration also gives both machines one PeerId, which already breaks control
1152
+ * networking well before any strand launches. Revisit if machines ever share an
1153
+ * owner key while holding distinct transport identities — the derivation would
1154
+ * then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
1155
+ */
1156
+ private isSelfFoundedRow;
860
1157
  /**
861
1158
  * The owner keypair (base64url Ed25519) derived from this node's resolved
862
1159
  * identity key. In the single-key reference model the owner signing key is
@@ -927,7 +1224,7 @@ export declare class CadreNode implements SAppIdLookup {
927
1224
  * Applied by ALL THREE sources of control-dial candidates, so no call site
928
1225
  * invents its own rule and none can produce a mixed list: the signed record
929
1226
  * ({@link resolvePeerAddrs}), the libp2p address book
930
- * ({@link peerStoreAddrs}), and a seed's retained owner addresses
1227
+ * ({@link peerStoreAddrs}), and a retained out-of-band dial target
931
1228
  * ({@link bootstrapDialAddrs}).
932
1229
  *
933
1230
  * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
@@ -939,10 +1236,15 @@ export declare class CadreNode implements SAppIdLookup {
939
1236
  * One address bound to `peerId`, or `null` when it cannot be — logged either
940
1237
  * way, never thrown, so every caller's list-shaping stays total.
941
1238
  *
1239
+ * Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
1240
+ * retained bootstrap addr) and to normalize the ones it ANNOUNCES
1241
+ * ({@link getStrandMultiaddrs}); the rule is the same either way — an address
1242
+ * that does not reach `peerId` is not an address for `peerId`.
1243
+ *
942
1244
  * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
943
1245
  * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
944
- * above parsed it already), reachable for a seed-supplied one
945
- * ({@link bootstrapDialAddrs}).
1246
+ * above parsed it already) and for our own node's id, reachable for a
1247
+ * retained one ({@link bootstrapDialAddrs}).
946
1248
  */
947
1249
  private bindAddrToPeer;
948
1250
  /** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
@@ -956,13 +1258,32 @@ export declare class CadreNode implements SAppIdLookup {
956
1258
  * `self:peer:update` (all wired in {@link startRecordRefresh}).
957
1259
  *
958
1260
  * Concurrent triggers collapse into a single in-flight pass
959
- * (see {@link reconcileControlCohortInFlight}) so two passes never double-dial.
1261
+ * (see {@link reconcileControlCohortInFlight}) so two passes never double-dial;
1262
+ * a call that joins an in-flight pass resolves to THAT pass's result.
960
1263
  * Best-effort throughout: a failure to resolve/dial any one sibling is logged
961
1264
  * and the pass continues; the whole pass is a no-op when the node is alone.
1265
+ *
1266
+ * Resolves to the peers the pass dialled ({@link ControlCohortReconcileResult}),
1267
+ * so a caller can tell a link this pass opened from one something else opened —
1268
+ * the pass skips a peer that is already connected. The timer and event triggers
1269
+ * discard it.
962
1270
  */
963
- reconcileControlCohort(): Promise<void>;
1271
+ reconcileControlCohort(): Promise<ControlCohortReconcileResult>;
964
1272
  /** Body of {@link reconcileControlCohort}; serialised by its single-flight guard. */
965
1273
  private runReconcileControlCohort;
1274
+ /**
1275
+ * The owner-only half of the reconcile pass's ledger-marker step: file the singleton
1276
+ * `Revocation` marker ({@link SeedBootstrapService.openRevocationLedger}) unless this
1277
+ * process has already seen it filed. The connectivity gate is the caller's
1278
+ * ({@link runReconcileControlCohort}); why the marker exists is on
1279
+ * {@link ControlDatabase.openRevocationLedger}.
1280
+ *
1281
+ * Sets {@link revocationLedgerOpened} on `'opened'` or `'already-open'`. A node that
1282
+ * cannot sign as an owner does nothing: it picks the owner's marker up the first time it
1283
+ * reads the table, like any other replicated row. Best-effort like every step of the
1284
+ * pass — a failure is logged and leaves the flag clear, so the next connected pass retries.
1285
+ */
1286
+ private openRevocationLedgerIfDue;
966
1287
  /**
967
1288
  * Resolve every sibling's signed address record once and merge what resolves
968
1289
  * into the control node's libp2p **address book** (peerStore). Returns the
@@ -982,6 +1303,9 @@ export declare class CadreNode implements SAppIdLookup {
982
1303
  * resolves to nothing (revoked, stale, untrusted) is not written at all, so its
983
1304
  * existing entry ages out on its own.
984
1305
  *
1306
+ * The same resolution also keeps a sibling's retained out-of-band dial target
1307
+ * current ({@link refreshDialHint}).
1308
+ *
985
1309
  * NOTE: this resolves EVERY sibling serially before the dial loop below runs,
986
1310
  * so it costs one record query per sibling per reconcile pass (~15s) and each
987
1311
  * one delays the pass's first dial. A cadre is a handful of devices, so today
@@ -989,6 +1313,28 @@ export declare class CadreNode implements SAppIdLookup {
989
1313
  * into one query, merge only on change, or move the warm pass after the dials.
990
1314
  */
991
1315
  private warmSiblingAddrBook;
1316
+ /**
1317
+ * Replace a sibling's retained out-of-band dial target (see
1318
+ * {@link bootstrapPeerStore}) with the addresses its signed record just resolved
1319
+ * to, when the two differ — so an address change this node saw while the record
1320
+ * was fresh (a new port, a new LAN address) is what it dials after a relaunch
1321
+ * that outlives the record's freshness window.
1322
+ *
1323
+ * Only a sibling that already HAS an entry (`retained`) is refreshed: the store
1324
+ * holds peers learned out of band and must not grow into a copy of every
1325
+ * sibling's addresses. An empty resolution leaves the entry alone — a stale or
1326
+ * not-yet-signed record is exactly when the entry is needed. The comparison is
1327
+ * order-insensitive and runs on the retained addresses after binding them to the
1328
+ * peer id, so an entry recorded without `/p2p/` suffixes is not rewritten merely
1329
+ * for lacking them.
1330
+ *
1331
+ * NOTE: replaces rather than merges, so the entry becomes exactly what the
1332
+ * sibling announces. If a node's signed record ever lists fewer addresses this
1333
+ * node can reach than it was added with (say an announce override naming only a
1334
+ * public address, for a node added by its LAN address), a relaunch dials the
1335
+ * worse set; merge the two lists here if that shows up.
1336
+ */
1337
+ private refreshDialHint;
992
1338
  /**
993
1339
  * {@link resolvePeerAddrs} for one sibling, best-effort: a control-DB read
994
1340
  * failure yields `[]` (and the cold-start fallback then gets its turn) rather
@@ -996,37 +1342,78 @@ export declare class CadreNode implements SAppIdLookup {
996
1342
  */
997
1343
  private resolveSiblingAddrs;
998
1344
  /**
999
- * Budget for ONE proactive dial of the reconcile pass — steady-state sibling
1345
+ * Time limits for dialing ONE control-network peer from its candidate
1346
+ * addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
1000
1347
  * ({@link dialControlSibling}) and cold-start bootstrap peer
1001
- * ({@link dialBootstrapPeer}) alike, since both feed a whole address list to
1002
- * one `dial()` and both run in a sequential loop that the next entry waits on.
1348
+ * ({@link dialBootstrapPeer}) dials, and — handed to every
1349
+ * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
1350
+ * `dialInvite`.
1003
1351
  *
1004
- * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why the pass bounds
1005
- * this itself rather than inheriting each address's libp2p attempt timeout.
1352
+ * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why a peer's dial is
1353
+ * bounded as a whole, and {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}
1354
+ * for why each address is bounded as well.
1006
1355
  */
1007
- private controlDialTimeoutMs;
1356
+ private controlDialBudget;
1008
1357
  /**
1009
1358
  * Dial one sibling from its already-resolved addresses, best-effort. Returns
1010
- * whether a dial was attempted (false when no address resolves).
1359
+ * whether the dial resolved (false when no address resolves or the dial fails).
1011
1360
  *
1012
1361
  * A per-peer failure (NAT, offline, relay down, connection-gater denial, or
1013
- * the {@link controlDialTimeoutMs} budget expiring) is logged and swallowed so
1014
- * one unreachable sibling never aborts the pass — exactly like
1362
+ * the {@link controlDialBudget} expiring) is logged and swallowed so one
1363
+ * unreachable sibling never aborts the pass — exactly like
1015
1364
  * {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
1016
1365
  * simply retried on the next pass.
1017
1366
  */
1018
1367
  private dialControlSibling;
1019
1368
  /**
1020
- * A sibling's control-network dial addresses for the reconcile pass.
1021
- *
1022
- * Primary (steady state): the signed, fresh, trust-gated control addresses
1023
- * `resolved` for it by {@link warmSiblingAddrBook} — passed in rather than
1024
- * re-resolved, so the pass makes one record query per sibling. Fallback (cold
1025
- * start): the libp2p peerStore entries `applySeed` populated, used only while
1026
- * the record is not yet resolvable. Returns `[]` (never throws) when neither
1027
- * yields an address — that sibling is skipped this pass.
1369
+ * A sibling's control-network dial addresses for the reconcile pass, from the
1370
+ * first of three sources that yields any:
1371
+ *
1372
+ * 1. the signed, fresh, trust-gated control addresses `resolved` for it by
1373
+ * {@link warmSiblingAddrBook} — passed in rather than re-resolved, so the
1374
+ * pass makes one record query per sibling;
1375
+ * 2. the libp2p address book ({@link peerStoreAddrs}) — the entries `applySeed`
1376
+ * and identify put there, until they age out;
1377
+ * 3. the sibling's retained out-of-band dial target ({@link retainedDialAddrs}).
1378
+ * For a node that cannot listen, this is the only source for a sibling it
1379
+ * added but has not yet connected to: the added node's row stays unsigned
1380
+ * until it self-publishes, which needs the connection this dial opens. It is
1381
+ * also what a relaunch that outlived the record's freshness window uses.
1382
+ *
1383
+ * Returns `[]` (never throws) when none yields an address — that sibling is
1384
+ * skipped this pass.
1385
+ *
1386
+ * The list may name transports this node cannot dial (a lent node reports TCP
1387
+ * and `/ws` addresses to a phone that dials WebSockets only). That needs no
1388
+ * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.1.3)
1389
+ * rejects an address no transport can dial before touching the network, so
1390
+ * such an address costs a log line.
1391
+ *
1392
+ * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
1393
+ * addresses one after another under a single deadline, with no limit per
1394
+ * address, and sorts loopback addresses last — so one or two addresses that
1395
+ * never answer used up the whole deadline before the address that worked was
1396
+ * tried. {@link dialPeerAddrs} dials each address on its own time limit instead.
1028
1397
  */
1029
1398
  private resolveControlDialAddrs;
1399
+ /**
1400
+ * A sibling's retained out-of-band dial target (see {@link bootstrapPeerStore}),
1401
+ * bound to its peer id through {@link bootstrapDialAddrs}; `[]` when it has none.
1402
+ *
1403
+ * These addresses are never merged into the libp2p address book: they are
1404
+ * unverified, and the address book takes only verified ones (see
1405
+ * `mergePeerAddrs`). Layers that dial by bare peer id use the connection this
1406
+ * dial opens, and after it drops the next reconcile pass dials again.
1407
+ *
1408
+ * NOTE: an entry is consulted here only for a current sibling, so a peer revoked
1409
+ * by another owner is not dialed from it, even though the entry stays (only this
1410
+ * node's own {@link removePeer} forgets one). The cold-start branch
1411
+ * ({@link dialColdStartBootstrap}) still dials every entry while there are no
1412
+ * siblings at all; if entries for revoked peers ever cause dial churn there,
1413
+ * prune entries whose peer has a retired `CadrePeer` row during the membership
1414
+ * refresh.
1415
+ */
1416
+ private retainedDialAddrs;
1030
1417
  /**
1031
1418
  * Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
1032
1419
  * {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
@@ -1059,15 +1446,33 @@ export declare class CadreNode implements SAppIdLookup {
1059
1446
  * signature-checked against a trust-anchored signer before this runs, and the
1060
1447
  * flag only *selects a dial target*; a dial grants no authority.
1061
1448
  *
1062
- * Self is excluded. `createSeed` projects EVERY `CadrePeer` row, so an owner
1063
- * that applies a seed minted after it joined finds itself in the owner list;
1064
- * retaining that would make the cold-start pass dial this node forever (the
1065
- * steady-state pass filters self out of its sibling list for the same reason).
1449
+ * Addressless peers and self are skipped by {@link retainDialTarget}.
1066
1450
  */
1067
1451
  private recordSeedBootstrapPeers;
1068
1452
  /**
1069
- * Cold-start branch of the reconcile pass: re-dial the seed's owner peers
1070
- * while the control database still has no siblings to dial.
1453
+ * Retain (or replace) one peer's out-of-band dial target in
1454
+ * {@link bootstrapPeerStore}. Shared by every writer: seed intake, {@link addDrone}
1455
+ * and {@link refreshDialHint}.
1456
+ *
1457
+ * Fire-and-log: the entry is visible synchronously by the store's contract, and
1458
+ * the promise tracks durability only. A persist failure costs restart survival,
1459
+ * never this session's dial set — the same trade
1460
+ * `SeedBootstrapService.anchorAcceptedSigner` makes — so it is logged rather than
1461
+ * failing a seed or an add that has already been accepted.
1462
+ *
1463
+ * Two peers are never retained. One with no address: there is nothing to dial.
1464
+ * And self: `createSeed` projects EVERY `CadrePeer` row, so an owner that applies
1465
+ * a seed minted after it joined finds itself in the owner list, and `addDrone`
1466
+ * could be handed this node's own id. Retaining self would make the cold-start
1467
+ * pass dial this node forever (the steady-state pass filters self out of its
1468
+ * sibling list for the same reason).
1469
+ */
1470
+ private retainDialTarget;
1471
+ /**
1472
+ * Cold-start branch of the reconcile pass: re-dial every retained out-of-band
1473
+ * dial target (see {@link bootstrapPeerStore}) — in practice the owner peers of
1474
+ * the seeds this node applied — while the control database still has no
1475
+ * siblings to dial.
1071
1476
  *
1072
1477
  * `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
1073
1478
  * When that single dial fails — owner momentarily down, relay reservation not
@@ -1090,13 +1495,14 @@ export declare class CadreNode implements SAppIdLookup {
1090
1495
  */
1091
1496
  private dialColdStartBootstrap;
1092
1497
  /**
1093
- * Bind a bootstrap peer's seed addresses to its peer id, so the dial
1094
- * authenticates the peer it is aiming at rather than trusting whoever answers.
1498
+ * Bind a retained dial target's addresses (see {@link bootstrapPeerStore}) to
1499
+ * its peer id, so the dial authenticates the peer it is aiming at rather than
1500
+ * trusting whoever answers.
1095
1501
  *
1096
1502
  * The same {@link normalizeDialAddrs} rule the resolved and address-book paths
1097
1503
  * use, on the third and last source of control-dial candidates: an address that
1098
1504
  * already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
1099
- * (a seed that disagrees with itself is not a dial target); one that does not —
1505
+ * (an entry that disagrees with itself is not a dial target); one that does not —
1100
1506
  * a bare listen addr, or a relay hop with the destination missing — gets the id
1101
1507
  * encapsulated; an unencapsulatable one is dropped rather than dialed bare.
1102
1508
  */
@@ -1354,6 +1760,15 @@ export declare class CadreNode implements SAppIdLookup {
1354
1760
  * `control:disconnected` emit, which a never-connected start has no edge for.
1355
1761
  */
1356
1762
  private cleanup;
1763
+ /**
1764
+ * A gated joiner's database was just published (`StartStrandConfig.onWritable`): the
1765
+ * instance is `'active'` now, so re-arm its idle timer as any activity would. Nothing
1766
+ * records activity on a gated joiner, so its idle timer may already have fired — the
1767
+ * instance read `'idle'` with a hibernate timer pending — and without this the flip
1768
+ * back to `'active'` would leave that timer to quiesce a strand the app just started
1769
+ * using. Emits `strand:writable` after the timers are settled.
1770
+ */
1771
+ private handleStrandWritable;
1357
1772
  private handleStrandIdle;
1358
1773
  /**
1359
1774
  * Hibernate a strand: release its strand-network resources via the strand
@@ -1375,8 +1790,8 @@ export declare class CadreNode implements SAppIdLookup {
1375
1790
  /**
1376
1791
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
1377
1792
  * first: the discovery seed may have grown since the strand last ran. Shared by
1378
- * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
1379
- * both apply the same fresh seed resolution. `resumeStrand` is idempotent
1793
+ * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
1794
+ * apply the same fresh resolution. `resumeStrand` is idempotent
1380
1795
  * (returns the live instance unchanged) as a backstop against double-resume.
1381
1796
  */
1382
1797
  private resumeStrandRuntime;
@@ -1437,42 +1852,152 @@ export declare class CadreNode implements SAppIdLookup {
1437
1852
  * Add a strand with its sApp configuration.
1438
1853
  * The hosting application must provide the sApp schema when creating a strand.
1439
1854
  *
1855
+ * This is the ATTACH half only — it starts the local instance and never publishes the
1856
+ * `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
1857
+ * FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
1858
+ * With no explicit `founder` flag, founder-ness is derived from the row's
1859
+ * `FounderOwnerKey` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
1860
+ * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
1861
+ * anyone else's row joins, without the caller needing to know which it is.
1862
+ *
1440
1863
  * A rejected call leaves nothing running but DOES leave the sApp config
1441
1864
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1442
- * automatic relaunch need it. So once the strand's row is visible on the control
1443
- * network (via {@link publishStrand} or another member), a failed launch keeps
1444
- * being re-attempted in the background — each failure re-emitting `strand:error`
1445
- * — until it succeeds. {@link detachStrand} (reached via {@link stopStrand}) is
1446
- * what abandons a strand for good.
1865
+ * automatic relaunch need it. That background relaunch covers only a strand the
1866
+ * WATCHER launched: it forgets a row whose `onStrandAdded` threw and re-attempts it
1867
+ * on a later poll (each failure re-emitting `strand:error`) until it succeeds. A
1868
+ * strand claimed here after it was DISCOVERED gets no such retry — the watcher
1869
+ * already recorded it in its `knownStrands` when it offered it as
1870
+ * `strand:discovered`, and no later poll re-offers a strand it knows. This call has
1871
+ * also already dropped it from {@link getDiscoveredStrands}, so a failed claim
1872
+ * leaves the strand dormant until the caller retries or the node restarts (see
1873
+ * `backlog/bug-discovered-strand-lost-when-claim-fails`).
1874
+ * {@link detachStrand} (reached via {@link stopStrand}) is what abandons a strand
1875
+ * for good.
1447
1876
  */
1448
1877
  addStrand(config: StrandConfig): Promise<StrandInstance>;
1878
+ /**
1879
+ * Resolve once a launched strand is writable — its database published to the app and
1880
+ * its status `'active'` — or reject with the retryable `StrandAwaitingFirstSyncError`
1881
+ * after `timeoutMs` (default `strandFirstSync.timeoutMs`, else
1882
+ * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`). The promise form of the `strand:writable`
1883
+ * event, for a caller that attached without waiting ({@link StrandConfig.awaitFirstSync}
1884
+ * `false`) or that holds a strand the watcher launched. Resolves immediately for a
1885
+ * strand that is already writable; rejects immediately for one this node does not run.
1886
+ * A hibernating strand is not woken — wake it first if that is what is wanted.
1887
+ */
1888
+ whenStrandWritable(strandId: string, options?: {
1889
+ timeoutMs?: number;
1890
+ }): Promise<StrandInstance>;
1449
1891
  /**
1450
1892
  * Publish a strand row to the shared control database under this node's own
1451
1893
  * owner identity, so other cadre members discover it via control-network
1452
1894
  * sync (their {@link StrandWatcher} fires `strand:discovered`).
1453
1895
  *
1454
- * This is the owner-signed `Strand` INSERT that {@link addStrand}
1455
- * deliberately omits: `addStrand` only starts the LOCAL strand instance,
1456
- * whereas publishing makes the strand visible cadre-wide. A typical creator
1457
- * does both (start locally + publish); a discovering peer only does
1458
- * `addStrand` (the row already exists).
1896
+ * **Founding a strand is TWO steps** — this one (publish the row cadre-wide) and
1897
+ * {@link addStrand} (start the local instance, `founder: true` to write the strand's
1898
+ * `Header`/founding membership). `addStrand` deliberately omits the insert: it only
1899
+ * starts the LOCAL instance, whereas publishing makes the strand visible cadre-wide. A
1900
+ * discovering peer only does `addStrand` (the row already exists). Callers founding a
1901
+ * strand should use {@link foundStrand}, which performs both steps and is safe to
1902
+ * re-run — hand-rolling the pair is what left strands half-founded when an app was
1903
+ * killed between them.
1904
+ *
1905
+ * **Idempotent for identical content.** A live row whose `(Type, MemberPrivateKey)` match
1906
+ * the arguments is the state this call would have produced, so the call logs and returns
1907
+ * that row instead of writing — a publish interrupted after it committed can be repeated.
1908
+ * DIFFERENT content on the same id throws, naming the columns that differ, rather than
1909
+ * surfacing the raw `UNIQUE constraint failed: Strand.Id`: silently accepting it would
1910
+ * let a retry reopen a closed strand or swap the key gating its reads. The read and the
1911
+ * insert are not atomic, so a concurrent founder (two machines of one party, same id) is
1912
+ * caught on the insert's uniqueness rejection and resolved the same way — re-read, no-op
1913
+ * on a match, rethrow otherwise. Never an overwrite either way.
1914
+ *
1915
+ * A tombstoned strand is NOT resurrected here: {@link unpublishStrand} deletes the row, so
1916
+ * the idempotent branch is unreachable and the ordinary publish path re-seats it, exactly
1917
+ * as that method documents.
1918
+ *
1919
+ * **Already stuck on `UNIQUE constraint failed: Strand.Id`?** The row is already published
1920
+ * — attach, do NOT republish: `addStrand` (or {@link foundStrand}) with the id and, for a
1921
+ * closed strand, the `MemberPrivateKey` read back from the row. `unpublishStrand` + a
1922
+ * fresh publish also clears it for an OPEN strand, but is destructive for a closed one
1923
+ * (the key exists nowhere else). No app-data wipe is needed for either.
1459
1924
  *
1460
1925
  * The insert is signed with the ed25519 key behind this node's PeerId — which
1461
1926
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
1462
- * so peer identity and owner key are one and the same. That key must be
1463
- * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
1927
+ * so peer identity and owner key are one and the same. That key is also persisted on the
1928
+ * row as `FounderOwnerKey` (the schema pins the column to the verified signer), which is
1929
+ * what later lets any launch derive "this machine is the founder" from the row alone.
1930
+ * The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
1464
1931
  * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
1465
1932
  * Failing loudly here is intentional: a silently-unpublished strand would run
1466
1933
  * as a local-only island that no peer could ever discover or join.
1467
1934
  *
1935
+ * A CLOSED strand's publish also seats this party's own membership identity key —
1936
+ * the `StrandPartyKey` row {@link ensureStrandPartyKey} documents — which is what the
1937
+ * founder bootstrap derives `Member.Key`/`Manager.MemberKey` from. The
1938
+ * `memberPrivateKey` ARGUMENT stays the strand-wide read secret formation hands to
1939
+ * joiners; it derives nobody's identity.
1940
+ *
1468
1941
  * @param strandId - Unique strand identifier (typically the same id passed to
1469
1942
  * {@link addStrand}).
1470
1943
  * @param type - `'o'` for open (default) or `'c'` for closed.
1471
- * @param memberPrivateKey - Optional membership key for a closed strand.
1472
- * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1473
- * the control DB rejects the (unauthorized) insert.
1474
- */
1475
- publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<void>;
1944
+ * @param memberPrivateKey - Optional shared membership (read) key for a closed strand.
1945
+ * @returns The live `Strand` row — the one just inserted, or the matching one already
1946
+ * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
1947
+ * on a repeat it is the stored key, not the argument.
1948
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, a
1949
+ * row with the same id holds different content, or the control DB rejects the
1950
+ * (unauthorized) insert.
1951
+ */
1952
+ publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<StrandRow>;
1953
+ /**
1954
+ * Found a strand — publish its row cadre-wide AND start the local instance as its
1955
+ * founder — in one resumable call. The single entry point for creating a strand; a
1956
+ * caller joining one someone else founded uses {@link addStrand} alone.
1957
+ *
1958
+ * Safe to re-run from any point of interruption, which hand-rolling
1959
+ * {@link publishStrand} + {@link addStrand} is not. Either half can already have
1960
+ * happened:
1961
+ *
1962
+ * - **row already published** → the stored row is adopted rather than re-published, so a
1963
+ * run killed after the insert committed no longer dies on
1964
+ * `UNIQUE constraint failed: Strand.Id`. For a closed strand the row's STORED
1965
+ * `MemberPrivateKey` wins over `config.memberPrivateKey`: a caller that mints a key per
1966
+ * attempt (as the reference apps do) would otherwise present a key that does not match
1967
+ * the membership already seated in the strand.
1968
+ * - **instance already running** → the tracked instance is returned, and — because the
1969
+ * `Strand` row records its publishing machine (`FounderOwnerKey`) — a founder request
1970
+ * against an instance that was first launched as a joiner now runs the (idempotent,
1971
+ * insert-if-absent) founder bootstrap on it rather than silently skipping it
1972
+ * ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
1973
+ * attached first — the reference RN app's `strand:discovered` handler after a restart,
1974
+ * or this node's own {@link StrandWatcher} poll winning `launchStrand`'s
1975
+ * `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
1976
+ *
1977
+ * Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
1978
+ * resolved row's `FounderOwnerKey` is its own owner key. A fresh publish records this
1979
+ * machine, so the common path founds; a machine that lost a concurrent founding race to
1980
+ * a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
1981
+ * machines bootstrapping the same strand on separate replicas is the double-`Header`
1982
+ * hazard. The returned {@link FoundStrandResult.founded} says which happened.
1983
+ *
1984
+ * `Type` is compared, not adopted: a stored row of the other type means the caller and
1985
+ * the control plane disagree about what this strand IS, so it throws.
1986
+ *
1987
+ * @returns The instance AND the row the strand actually runs under — read the membership
1988
+ * key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
1989
+ * @throws if the node is not started, the id is blank, a published row of the same id has
1990
+ * a different `Type`, or either half rejects.
1991
+ */
1992
+ foundStrand(config: FoundStrandConfig): Promise<FoundStrandResult>;
1993
+ /**
1994
+ * Resume onto an already-published `Strand` row: keep its stored content, reject a type
1995
+ * disagreement. The stored `MemberPrivateKey` is authoritative — see {@link foundStrand}
1996
+ * for why a caller-minted key must lose here (and why that is NOT the same rule as
1997
+ * {@link publishStrand}'s, which throws on a key mismatch because it is being asked to
1998
+ * WRITE that key, not to resume onto what already exists).
1999
+ */
2000
+ private adoptPublishedStrand;
1476
2001
  /**
1477
2002
  * Remove this party's `Strand` row from the shared control database — the owner-signed
1478
2003
  * inverse of {@link publishStrand}.
@@ -1486,10 +2011,12 @@ export declare class CadreNode implements SAppIdLookup {
1486
2011
  * is an owner-signed control-plane write like every other, not "destroy the network".
1487
2012
  *
1488
2013
  * Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
1489
- * this party's membership secret for that network, and it is stored nowhere else.
1490
- * With it gone the party can never again admit a member to that closed strand, and a
1491
- * re-published row would carry a DIFFERENT key that does not match the membership
1492
- * already written into the strand's RBAC layer. The strand id itself is NOT
2014
+ * this party's read secret for that network, and the strand's `StrandPartyKey` row —
2015
+ * this party's own membership identity, removed (and tombstoned) in the SAME
2016
+ * transaction — is stored nowhere else either. With both gone the party can never
2017
+ * again sign as its seated member/manager there, and a re-published row mints a fresh
2018
+ * identity that does not match the membership already written into the strand's RBAC
2019
+ * layer. The strand id itself is NOT
1493
2020
  * blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
1494
2021
  * stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
1495
2022
  * `Revocation` tombstone names the id, which the consent branch of
@@ -1529,6 +2056,55 @@ export declare class CadreNode implements SAppIdLookup {
1529
2056
  * there throws over a completed removal.
1530
2057
  */
1531
2058
  unpublishStrand(strandId: string): Promise<void>;
2059
+ /**
2060
+ * Seat this party's own strand membership identity key — the `StrandPartyKey` row —
2061
+ * for `strandId`, minting a fresh one when none is given, and adopting the stored one
2062
+ * when a row already exists. Insert-if-absent and stable thereafter: the founding
2063
+ * `Member.Key` must not change across restarts, or the bootstrap's insert-if-absent
2064
+ * guards stop matching.
2065
+ *
2066
+ * This is the identity half of the closed-strand key split: the row's `PrivateKey` —
2067
+ * NOT the strand row's shared `MemberPrivateKey`, which formation hands to every
2068
+ * joining party — is what the founder bootstrap derives `Member.Key`/`Manager.MemberKey`
2069
+ * from. It replicates to every machine this party owns (same plaintext-at-rest stance
2070
+ * as `MemberPrivateKey`; docs/strands.md → "Closed-Strand Member Key Handling") and is
2071
+ * never put on the formation wire.
2072
+ *
2073
+ * Called internally by {@link publishStrand} (closed strands mint at publish) and by a
2074
+ * founder launch that finds no row (a publish interrupted before its mint); public so a
2075
+ * test harness — or a joiner flow that persists a formation-issued identity — can seat
2076
+ * a specific key deliberately.
2077
+ *
2078
+ * @param strandId - The strand the key is this party's identity for.
2079
+ * @param partyMemberPrivateKey - Optional specific key (base64 protobuf, as
2080
+ * `generateStrandMemberKey` mints). When a row already holds a DIFFERENT key this
2081
+ * throws — a party has one identity per strand; rotation is a deliberate
2082
+ * remove-then-insert, not a silent swap.
2083
+ * @returns The live party key: the one just seated, or the stored one.
2084
+ */
2085
+ ensureStrandPartyKey(strandId: string, partyMemberPrivateKey?: string): Promise<string>;
2086
+ /**
2087
+ * Resolve the party membership key a closed strand's launch threads into the founder
2088
+ * bootstrap: the explicit attach-time key when given, else the party's persisted
2089
+ * `StrandPartyKey` row, else — on the one machine whose owner key the row names as
2090
+ * founder — a freshly minted-and-persisted key, which seats the identity for a publish
2091
+ * that was interrupted before its mint. Everyone else resolves undefined: non-founding
2092
+ * machines never mint (no mint race between a party's machines), and only a founder
2093
+ * bootstrap needs the key at all.
2094
+ *
2095
+ * Minting does NOT repair a strand founded before the key split: its founding
2096
+ * membership was seated under the shared `MemberPrivateKey`, and nothing re-seats it —
2097
+ * the founder bootstrap refuses such a strand (`PreSplitStrandIdentityError`) instead.
2098
+ *
2099
+ * NOTE: "founding machine" here is the ROW's provenance, not the launch's resolved
2100
+ * `founder` flag — so an explicit `founder: false` over a row this machine published
2101
+ * still mints, an owner-signed write a caller that said "I am not founding" did not ask
2102
+ * for. Harmless (the party needs that identity for the strand either way, and the mint
2103
+ * is insert-if-absent) and unreachable today: the one explicit `founder: false` caller
2104
+ * passes a row with a null `FounderOwnerKey`. If a caller ever pairs `founder: false`
2105
+ * with its own published row, gate the mint on the resolved flag too.
2106
+ */
2107
+ private resolveStrandPartyKey;
1532
2108
  /**
1533
2109
  * Publish an owner-signed `FormationInvite` (open-invitation token) to the
1534
2110
  * shared control database, so a later {@link formStrand} redemption can be
@@ -1624,16 +2200,36 @@ export declare class CadreNode implements SAppIdLookup {
1624
2200
  * seed, starts the strand, and registers it with the hibernation manager
1625
2201
  * before emitting `strand:started`.
1626
2202
  *
1627
- * Idempotent no-op when the strand manager already tracks `strand.Id` — the
1628
- * ordinary founding sequence is `addStrand` (starts it locally) followed by
1629
- * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
1630
- * rediscovers a row it already started and calls this again via
1631
- * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
2203
+ * Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
2204
+ * one deliberately — their consent-seated rows carry a null `FounderOwnerKey`); when
2205
+ * unset it is DERIVED from the row, so the control-discovered path
2206
+ * (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
2207
+ * without the caller having to know. The derivation is pure key comparison — no I/O.
2208
+ *
2209
+ * Idempotent when the strand manager already tracks `strand.Id` — the watcher
2210
+ * rediscovers a row this node already started and calls this again via
2211
+ * `handleStrandAdded`; without the guard that re-entry would resolve a fresh
1632
2212
  * (already-connected) cohort seed and re-emit `strand:started` for an
1633
2213
  * instance that never stopped. The guard runs before the cohort-seed RPC
1634
- * fan-out, so a rediscovery costs nothing beyond the map lookup.
2214
+ * fan-out, so a rediscovery costs nothing beyond the map lookup plus the (pure)
2215
+ * founder derivation. NOT a silent no-op any more when the launch resolves as a
2216
+ * FOUNDER: the tracked instance may have been launched first as a joiner (an app
2217
+ * attach, or this node's own watcher poll winning the `resolveCohortSeed` window
2218
+ * below), which used to drop the founder request and leave the strand headerless —
2219
+ * now the tracked instance is founded in place
2220
+ * ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
2221
+ * so the bootstrap actually runs before this resolves.
2222
+ *
2223
+ * A founder launch refused as pre-split (`PreSplitStrandIdentityError`) is recorded in
2224
+ * {@link strandLaunchRefusals} for the formation arm, then rethrown; a founder launch
2225
+ * that succeeds clears the record (it ran the bootstrap's pre-split check and passed).
1635
2226
  */
1636
2227
  private launchStrand;
2228
+ /**
2229
+ * The launch itself, for {@link launchStrand} (which documents the behaviour): found a
2230
+ * tracked instance in place, or start a fresh one.
2231
+ */
2232
+ private startOrFoundStrand;
1637
2233
  /**
1638
2234
  * Resolve a strand's discovery seed — the dialable strand-network multiaddr
1639
2235
  * strings for cohort siblings. Membership comes from the control network's
@@ -1661,6 +2257,13 @@ export declare class CadreNode implements SAppIdLookup {
1661
2257
  * for a relay we are not yet connected to.
1662
2258
  */
1663
2259
  private resolveCohortSeed;
2260
+ /**
2261
+ * The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
2262
+ * from CONNECTED cohort siblings over the control-mesh strand-addr RPC, doubling as
2263
+ * the delegate announcement when `delegatePeerId` is given. Empty when the control DB
2264
+ * or node is absent (not yet started / torn down).
2265
+ */
2266
+ private resolveSiblingSeed;
1664
2267
  /**
1665
2268
  * The co-cadre siblings worth sending a strand-addr RPC to right now: cohort
1666
2269
  * members (self excluded) we already hold an open control connection to. They
@@ -1726,10 +2329,10 @@ export declare class CadreNode implements SAppIdLookup {
1726
2329
  * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
1727
2330
  * per-peer failure to `[]` and reports no per-peer success, and threading
1728
2331
  * success out would change its API for little gain. A failed INITIAL announce
1729
- * is fatal-at-start anyway (the relay denies the reservation,
1730
- * `libp2p.start()` throws, and wake/check-in re-resume retries with a fresh
1731
- * announce); a failed REFRESH retries within `DELEGATE_GRANT_TTL_MS / 2`
1732
- * (15 min), still inside the 30 min TTL.
2332
+ * costs the strand supervisor its first attempt only (the relay denies the
2333
+ * reservation; every re-drive re-announces first through
2334
+ * {@link announceDelegateToRelay}); a failed REFRESH retries within
2335
+ * `DELEGATE_GRANT_TTL_MS / 2` (15 min), still inside the 30 min TTL.
1733
2336
  */
1734
2337
  private recordDelegateAnnounces;
1735
2338
  /**
@@ -1746,6 +2349,37 @@ export declare class CadreNode implements SAppIdLookup {
1746
2349
  * target, which must not stack up per strand.
1747
2350
  */
1748
2351
  private refreshDelegateGrants;
2352
+ /**
2353
+ * Announce ONE strand's delegate peerId to ONE relay, UNTHROTTLED — the
2354
+ * `beforeRedrive` hook of that strand node's per-relay reservation supervisor
2355
+ * (`strand-instance-manager.ts` → `buildStrandRuntime`), run before every
2356
+ * re-drive after the first attempt.
2357
+ *
2358
+ * Why a re-drive must re-announce first: a party control node running the relay
2359
+ * server admits the strand node's derived peerId on an in-memory delegate grant
2360
+ * (`delegate-admission.ts`), and a relay restart drops every grant it held
2361
+ * without telling the announcer. {@link refreshDelegateGrants} would re-announce
2362
+ * on its own at most every `DELEGATE_GRANT_TTL_MS / 2` (15 min) per (relay,
2363
+ * strand) — far slower than the supervisor's backoff — so without this the first
2364
+ * re-drives after a relay restart would be denied at the relay's connection gate.
2365
+ * The throttle map is updated afterwards, so the periodic pass does not announce
2366
+ * again right away.
2367
+ *
2368
+ * Against a dedicated ops relay (no strand-addr RPC) the request fails per-peer
2369
+ * and `collectStrandAddrs` folds it to `[]`: one wasted protocol negotiation per
2370
+ * re-drive attempt, bounded by the supervisor's backoff. Never throws on that
2371
+ * path; a relay addr that names no peer id is logged and skipped (the hook's
2372
+ * caller runs the drive regardless).
2373
+ *
2374
+ * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
2375
+ * timeouts (10 s each: dial by peer id, then by addr) before the 10 s drive even
2376
+ * starts, so one failed re-drive can hold the supervisor `driving` for ~30 s.
2377
+ * Bounded and harmless while the relay is unreachable anyway; if recovery
2378
+ * latency after a relay comes back ever matters, skip the announce when the
2379
+ * control node holds no connection to the relay (the drive's own dial fails
2380
+ * faster) rather than shortening the strand-addr timeout.
2381
+ */
2382
+ private announceDelegateToRelay;
1749
2383
  /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
1750
2384
  private announceDelegateToDueRelays;
1751
2385
  /**
@@ -1774,9 +2408,18 @@ export declare class CadreNode implements SAppIdLookup {
1774
2408
  */
1775
2409
  private refreshStrandPeerAddrs;
1776
2410
  /**
1777
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, then
1778
- * merge their answers into this strand's address book. Errors are logged and
1779
- * swallowed so one strand's failure never costs the others their refresh.
2411
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
2412
+ * answers with this strand's cross-party contact addresses, and merge the lot into the
2413
+ * strand's address book. Errors are logged and swallowed so one strand's failure never
2414
+ * costs the others their refresh.
2415
+ *
2416
+ * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
2417
+ * The cross-party addresses still have to be re-merged in that case: they were learned
2418
+ * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
2419
+ * thing standing between them and the peerStore's one-hour expiry. A strand with
2420
+ * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
2421
+ * unstamped, so the next reconcile tick retries rather than sitting out the whole
2422
+ * refresh interval having done nothing.
1780
2423
  */
1781
2424
  private refreshOneStrandPeerAddrs;
1782
2425
  /**
@@ -1797,6 +2440,15 @@ export declare class CadreNode implements SAppIdLookup {
1797
2440
  * handshake, and a bad entry costs one failed dial that ages out at the
1798
2441
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
1799
2442
  * `backlog/strand-network-nat-relay-reachability`.
2443
+ *
2444
+ * NOTE: one input does NOT age out — the cross-party addresses a formation
2445
+ * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
2446
+ * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
2447
+ * also means a junk entry from a responder survives for the node's lifetime rather
2448
+ * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
2449
+ * node deliberately formed a strand with, and still authority-free — so the
2450
+ * exposure is a handful of failed dials, not a trust hole. Evict on
2451
+ * `unpublishStrand` if that ever stops being an acceptable price.
1800
2452
  */
1801
2453
  private mergeStrandPeerAddrs;
1802
2454
  /**
@@ -1812,22 +2464,32 @@ export declare class CadreNode implements SAppIdLookup {
1812
2464
  /**
1813
2465
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
1814
2466
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
1815
- * intact, so on the next node restart (or watcher poll) the strand is rediscovered and
1816
- * surfaces as `strand:discovered` again. Party-wide removal is {@link unpublishStrand}.
2467
+ * intact, so on the next node RESTART the strand is rediscovered and surfaces as
2468
+ * `strand:discovered` again — not on the next watcher poll, which never re-offers a
2469
+ * strand it has already seen (`StrandWatcher.knownStrands` retains the id for the life
2470
+ * of the process). The stop also drops the strand from {@link getDiscoveredStrands},
2471
+ * so a drain cannot undo a deliberate stop. Party-wide removal is
2472
+ * {@link unpublishStrand}.
1817
2473
  */
1818
2474
  stopStrand(strandId: string): Promise<void>;
1819
2475
  /**
1820
2476
  * Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
1821
- * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config, stop
1822
- * the instance, emit `strand:stopped`. Touches no control-plane row — which side of the
1823
- * removal the node is on is the caller's concern, not this method's.
2477
+ * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config and any
2478
+ * recorded launch refusal, stop the instance, emit `strand:stopped`. Touches no
2479
+ * control-plane row — which side of the removal the node is on is the caller's concern,
2480
+ * not this method's.
1824
2481
  *
1825
2482
  * The stop + emit are skipped when the strand manager holds no instance for `strandId` —
1826
2483
  * e.g. a party owner that published a strand's row but never ran it locally, or an
1827
2484
  * explicit {@link stopStrand} for an id this node never started. `hibernationManager`
1828
- * untrack and the `sAppConfigs` delete stay unconditional (both are no-ops when there is
1829
- * nothing to remove), so a launch that failed before an instance was ever tracked still
1830
- * gets its stray sApp config cleared.
2485
+ * untrack and the `sAppConfigs` / {@link strandLaunchRefusals} / {@link discoveredStrands}
2486
+ * deletes stay unconditional (all no-ops when there is nothing to remove), so a launch
2487
+ * that failed before an instance was ever tracked still gets its stray entries cleared.
2488
+ *
2489
+ * Dropping the {@link discoveredStrands} entry here covers both callers, and both
2490
+ * readings are the intended one: `handleStrandRemoved` arrives because the control row
2491
+ * is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
2492
+ * strand may be re-offered to a later `getDiscoveredStrands()` drain.
1831
2493
  */
1832
2494
  private detachStrand;
1833
2495
  /**
@@ -1934,6 +2596,11 @@ export declare class CadreNode implements SAppIdLookup {
1934
2596
  * relay indistinguishable from a hung start, and much shorter would fail nodes
1935
2597
  * on links that were merely slow. The retries carry on in the background after
1936
2598
  * this resolves, exactly as they do for a {@link reserveRelays} caller.
2599
+ *
2600
+ * `network.requireRelay === false` softens only the outcome below: a first
2601
+ * attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
2602
+ * `start()` carries on with the retry supervisor already running in the
2603
+ * background (the same supervisor {@link reserveRelays} always starts).
1937
2604
  */
1938
2605
  private driveControlRelayReservation;
1939
2606
  /**
@@ -2191,6 +2858,23 @@ export declare class CadreNode implements SAppIdLookup {
2191
2858
  * best-effort (a failed read keeps the previous snapshot); never rejects.
2192
2859
  */
2193
2860
  refreshMembershipGate(reason?: string): Promise<void>;
2861
+ /**
2862
+ * Re-materialize a CLOSED strand's revoked-peer deny set now, and hang up any
2863
+ * connected peer it newly covers — the strand-side counterpart to
2864
+ * {@link refreshMembershipGate}.
2865
+ *
2866
+ * Unlike that one, NOTHING calls this automatically: strand membership is
2867
+ * written through the strand's own `Database` handle
2868
+ * (`strand-membership-writer.ts`), which raises no notification this runtime
2869
+ * can hook, and a revocation that arrives by REPLICATION raises none either.
2870
+ * The gate therefore polls (default 30 s). An app that has just called
2871
+ * `revokeMember` or `leaveStrand` should follow the write with this call so
2872
+ * the cut is immediate rather than up to one poll interval late.
2873
+ *
2874
+ * Quiet no-op for a strand that is not running, is quiesced, is open, or has
2875
+ * the gate disabled. Never rejects; resolves once the sweep has finished.
2876
+ */
2877
+ refreshRevocationEnforcement(strandId: string): Promise<void>;
2194
2878
  /**
2195
2879
  * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
2196
2880
  * until the stale flag stays clear, then release the slot.
@@ -2281,6 +2965,20 @@ export declare class CadreNode implements SAppIdLookup {
2281
2965
  /**
2282
2966
  * Add a drone to the cadre (for phone/server adding provider-hosted node).
2283
2967
  * Creates authorization and seed for drone initialization.
2968
+ *
2969
+ * Also retains the drone's handed-over addresses as a durable dial target (see
2970
+ * {@link bootstrapPeerStore}). The drone cannot dial an owner that does not
2971
+ * listen (a phone), and its `CadrePeer` row stays unsigned — so unresolvable —
2972
+ * until it self-publishes over a connection; this node therefore has to open
2973
+ * that connection from the addresses it was handed, on this launch or a later
2974
+ * one.
2975
+ *
2976
+ * Nothing is dialed here: the drone has not received the seed yet. After
2977
+ * delivering it, call {@link reconcileControlCohort} to dial straight away;
2978
+ * otherwise the next timed reconcile pass does. A pass already in flight is
2979
+ * joined rather than restarted, and one that listed siblings before this add
2980
+ * does not dial the drone, so a caller waiting for the connection should allow
2981
+ * for one more timed pass.
2284
2982
  */
2285
2983
  addDrone(options: AddDroneOptions): Promise<DroneInitResult>;
2286
2984
  /**
@@ -2337,6 +3035,89 @@ export declare class CadreNode implements SAppIdLookup {
2337
3035
  * @returns The member key and strand info if successful
2338
3036
  */
2339
3037
  formStrand(invitation: OpenInvitation, disclosure?: StrandFormationDisclosure): Promise<FormStrandResult>;
3038
+ /**
3039
+ * Adopt an approved closed-strand formation's membership half on the JOINER:
3040
+ *
3041
+ * 1. Mint-or-reuse this party's own strand identity — {@link ensureStrandPartyKey}
3042
+ * with no explicit key returns the stored `StrandPartyKey` row when one exists (a
3043
+ * re-formation by an existing member party: lost addresses, an app reinstall with
3044
+ * an intact control DB) and mints + persists a fresh one otherwise. The invitation
3045
+ * will admit THIS key's public half as the `Strand.Member`.
3046
+ * 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
3047
+ * bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`.
3048
+ *
3049
+ * Throws — failing the whole {@link formStrand} — when the identity cannot be
3050
+ * persisted: a joiner "joined" without a persistable identity could never become a
3051
+ * member, and failing loudly beats a silent half-member. The formation itself has
3052
+ * already succeeded by then and its one-time token is SPENT, so the error says so:
3053
+ * recovery is fixing the underlying cause (no owner signing key / control DB write
3054
+ * rejected) and redeeming a FRESH invitation.
3055
+ */
3056
+ private adoptFormationMembershipInvite;
3057
+ /**
3058
+ * The pending single-use membership invitation a closed-strand formation carried back
3059
+ * for `strandId`, or `undefined` when none is staged — the seam the strand bring-up
3060
+ * membership reconciler (`strand-membership-reconciler.ts`) reads to redeem the
3061
+ * joiner's `Strand.Member` seat. `undefined` therefore also means "already redeemed,
3062
+ * burned, or found dead": the reconciler clears the entry as soon as it settles the
3063
+ * invitation, so a caller polling this sees it disappear on its own. In-memory only;
3064
+ * see {@link pendingMembershipInvites} for lifetime and re-formation semantics.
3065
+ */
3066
+ getPendingMembershipInvite(strandId: string): StrandMembershipInvite | undefined;
3067
+ /**
3068
+ * Responder-side issuer behind the formation manager's `issueMembershipInvite` seam
3069
+ * (see `StrandFormationManagerOptions.issueMembershipInvite` for the contract this
3070
+ * implements): mint a single-use `Strand.Invite` against the LIVE host strand so a
3071
+ * validated joiner can seat its own `Strand.Member` row.
3072
+ *
3073
+ * - Open host strand → `null` (no members, nothing to invite into).
3074
+ * - Closed host strand whose founder launch was refused as pre-split
3075
+ * ({@link strandLaunchRefusals}) → rethrow the recorded `PreSplitStrandIdentityError`;
3076
+ * the manager maps it to the NON-retryable `HOST_STRAND_MUST_BE_RECREATED_REASON`.
3077
+ * - Closed host strand with no `StrandPartyKey` row → throw: this party's identity is
3078
+ * the invite's issuing manager, and without it nothing can sign the issuance. (The
3079
+ * founder's publish/launch paths mint it, so this is a not-yet-converged sibling.)
3080
+ * The manager maps the throw to a clean retryable rejection BEFORE the formation
3081
+ * token is spent.
3082
+ * - Closed host strand with no running local instance/database → throw, same mapping:
3083
+ * a joiner admitted without an invitation would look joined and never become a
3084
+ * member, and a responder not running the strand cannot serve its sync anyway.
3085
+ * - Closed host strand whose LIVE rows carry the pre-split fingerprint
3086
+ * (`assertNotPreSplitStrand`) → throw `PreSplitStrandIdentityError`, same mapping as
3087
+ * the recorded refusal. Covers the responders that never ran a refused founder launch:
3088
+ * a sibling machine of the founding party, or a node restarted since the refusal.
3089
+ *
3090
+ * The recorded refusal is checked first: it is an in-memory read and the only
3091
+ * permanent diagnosis. Identity is checked BEFORE the runtime: it is the cheaper read
3092
+ * and the more actionable diagnosis when both are missing (a missing runtime is
3093
+ * transient, a missing identity is not), and it keeps the branch reachable without
3094
+ * standing a strand runtime up.
3095
+ *
3096
+ * The invitation expires `MEMBERSHIP_INVITE_TTL_MS` from now — see that constant for
3097
+ * the slow-joiner / lost-result tradeoff.
3098
+ *
3099
+ * NOTE: the issuing identity must be a `Strand.Manager` (the schema's `InviteValid`
3100
+ * gate), and only the FOUNDING party's key is seated as one. Today only the founder
3101
+ * party can host a bound formation at all — a joining party never gets the host
3102
+ * strand's control `Strand` row, so `resolveStrand` reports `missing` on it — so this
3103
+ * never bites. If a joined party is ever able to host formations into a strand it
3104
+ * joined (re-invite / multi-hop join), issuance here fails the manager gate and every
3105
+ * such redemption rejects with the retryable-sounding
3106
+ * `MEMBERSHIP_INVITE_UNAVAILABLE_REASON` forever; that flow needs manager delegation,
3107
+ * not a retry.
3108
+ */
3109
+ private issueStrandMembershipInvite;
3110
+ /**
3111
+ * Remember the responder's strand-network addresses for a strand this node just
3112
+ * formed, so the strand's discovery seed has something to work with when the app
3113
+ * launches it (see {@link crossPartyStrandAddrs}).
3114
+ *
3115
+ * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
3116
+ * party and must never seed another strand's mesh or the control peerStore. An empty
3117
+ * list is recorded as no entry at all rather than an empty one, so a later formation
3118
+ * against the same strand that DOES disclose addresses is not shadowed.
3119
+ */
3120
+ private recordCrossPartyStrandAddrs;
2340
3121
  /**
2341
3122
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).
2342
3123
  */