@serfab/cadre-core 0.13.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 (131) hide show
  1. package/README.md +20 -6
  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 +558 -80
  7. package/dist/cadre-node.js +1060 -170
  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 +299 -44
  16. package/dist/control-database.js +469 -69
  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 +103 -19
  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.js +2 -2
  31. package/dist/index.d.ts +14 -7
  32. package/dist/index.js +33 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/node-local-snapshot.d.ts +15 -7
  35. package/dist/node-local-snapshot.js +23 -8
  36. package/dist/node-local-snapshot.js.map +1 -1
  37. package/dist/peer-dial.d.ts +143 -0
  38. package/dist/peer-dial.js +176 -0
  39. package/dist/peer-dial.js.map +1 -0
  40. package/dist/relay-addrs.d.ts +90 -52
  41. package/dist/relay-addrs.js +91 -59
  42. package/dist/relay-addrs.js.map +1 -1
  43. package/dist/relay-reservation.d.ts +71 -3
  44. package/dist/relay-reservation.js +116 -8
  45. package/dist/relay-reservation.js.map +1 -1
  46. package/dist/seed-bootstrap.d.ts +30 -1
  47. package/dist/seed-bootstrap.js +62 -17
  48. package/dist/seed-bootstrap.js.map +1 -1
  49. package/dist/storage-scope.d.ts +62 -0
  50. package/dist/storage-scope.js +75 -0
  51. package/dist/storage-scope.js.map +1 -0
  52. package/dist/strand-database.d.ts +34 -16
  53. package/dist/strand-database.js +38 -24
  54. package/dist/strand-database.js.map +1 -1
  55. package/dist/strand-first-sync-gate.d.ts +152 -0
  56. package/dist/strand-first-sync-gate.js +208 -0
  57. package/dist/strand-first-sync-gate.js.map +1 -0
  58. package/dist/strand-formation-manager.d.ts +87 -5
  59. package/dist/strand-formation-manager.js +109 -5
  60. package/dist/strand-formation-manager.js.map +1 -1
  61. package/dist/strand-formation-protocol.d.ts +21 -1
  62. package/dist/strand-formation-protocol.js +30 -0
  63. package/dist/strand-formation-protocol.js.map +1 -1
  64. package/dist/strand-instance-manager.d.ts +243 -6
  65. package/dist/strand-instance-manager.js +470 -37
  66. package/dist/strand-instance-manager.js.map +1 -1
  67. package/dist/strand-member-key.d.ts +20 -12
  68. package/dist/strand-member-key.js +20 -12
  69. package/dist/strand-member-key.js.map +1 -1
  70. package/dist/strand-member-registry.js +3 -0
  71. package/dist/strand-member-registry.js.map +1 -1
  72. package/dist/strand-membership-reconciler.d.ts +361 -0
  73. package/dist/strand-membership-reconciler.js +548 -0
  74. package/dist/strand-membership-reconciler.js.map +1 -0
  75. package/dist/strand-membership-writer.d.ts +131 -11
  76. package/dist/strand-membership-writer.js +396 -134
  77. package/dist/strand-membership-writer.js.map +1 -1
  78. package/dist/strand-network-config.d.ts +46 -14
  79. package/dist/strand-network-config.js +79 -23
  80. package/dist/strand-network-config.js.map +1 -1
  81. package/dist/strand-solicitation.d.ts +10 -1
  82. package/dist/strand-solicitation.js +5 -0
  83. package/dist/strand-solicitation.js.map +1 -1
  84. package/dist/strand-wake-protocol.d.ts +11 -7
  85. package/dist/strand-wake-protocol.js +20 -52
  86. package/dist/strand-wake-protocol.js.map +1 -1
  87. package/dist/strand-watcher.js +6 -0
  88. package/dist/strand-watcher.js.map +1 -1
  89. package/dist/timed-step.d.ts +12 -0
  90. package/dist/timed-step.js +28 -0
  91. package/dist/timed-step.js.map +1 -0
  92. package/dist/timeout-scheduler.d.ts +19 -0
  93. package/dist/timeout-scheduler.js +22 -0
  94. package/dist/timeout-scheduler.js.map +1 -0
  95. package/dist/types.d.ts +262 -30
  96. package/dist/types.js.map +1 -1
  97. package/package.json +8 -8
  98. package/src/bootstrap-peer-store-file.ts +2 -2
  99. package/src/bootstrap-peer-store.ts +69 -27
  100. package/src/cadre-node.ts +7301 -6353
  101. package/src/control-authorization.ts +2 -1
  102. package/src/control-cohort.ts +122 -130
  103. package/src/control-database.ts +537 -69
  104. package/src/control-read-retry.ts +202 -167
  105. package/src/control-retry.ts +297 -190
  106. package/src/control-schema.ts +103 -19
  107. package/src/control-write-retry.ts +525 -383
  108. package/src/enrolled-machine-store.ts +2 -2
  109. package/src/index.ts +83 -6
  110. package/src/node-local-snapshot.ts +25 -8
  111. package/src/peer-dial.ts +215 -0
  112. package/src/relay-addrs.ts +458 -436
  113. package/src/relay-reservation.ts +951 -828
  114. package/src/seed-bootstrap.ts +76 -18
  115. package/src/storage-scope.ts +78 -0
  116. package/src/strand-database.ts +50 -27
  117. package/src/strand-first-sync-gate.ts +258 -0
  118. package/src/strand-formation-manager.ts +140 -5
  119. package/src/strand-formation-protocol.ts +43 -1
  120. package/src/strand-instance-manager.ts +577 -37
  121. package/src/strand-member-key.ts +20 -12
  122. package/src/strand-member-registry.ts +3 -0
  123. package/src/strand-membership-reconciler.ts +680 -0
  124. package/src/strand-membership-writer.ts +527 -189
  125. package/src/strand-network-config.ts +102 -24
  126. package/src/strand-solicitation.ts +16 -1
  127. package/src/strand-wake-protocol.ts +21 -66
  128. package/src/strand-watcher.ts +6 -0
  129. package/src/timed-step.ts +28 -0
  130. package/src/timeout-scheduler.ts +28 -0
  131. package/src/types.ts +1789 -1554
@@ -1,11 +1,12 @@
1
1
  import type { Libp2p, PeerId } from '@libp2p/interface';
2
2
  import type { Multiaddr } from '@multiformats/multiaddr';
3
- import type { CadreNodeConfig, StrandInstance, StrandRow, StrandConfig, FoundStrandConfig, FoundStrandResult, SAppConfig, CadreNodeEvents, ControlNetworkSeed, ApplySeedResult, AddDroneOptions, AddPhoneOptions, DroneInitResult, InviteResult, CadreInvite, OpenInvitation, FormStrandResult, StrandFormationDisclosure, ResolveOpts, SelfRegistrationOutcome, ServiceWakeResult, PushPlatform, DeviceTokenRecord, ResolveDeviceTokenOpts } from './types.js';
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
7
  import { type EnrolledMachineStore } from './enrolled-machine-store.js';
8
8
  import { type SAppIdLookup } from './strand-watcher.js';
9
+ import { type ControlCohortReconcileResult } from './control-cohort.js';
9
10
  import { EnrollmentService } from './enrollment.js';
10
11
  import { ControlDatabase } from './control-database.js';
11
12
  import { SeedBootstrapService } from './seed-bootstrap.js';
@@ -91,10 +92,11 @@ export declare class CadreNode implements SAppIdLookup {
91
92
  private _running;
92
93
  /**
93
94
  * The control database's own raw storage (cache-wrapped), resolved once per
94
- * `start()` and released in {@link cleanup}. Owning it is what keeps
95
- * `config.storage.provider('control')` a once-per-runtime call: a `stop()` then
96
- * `start()` cycle on this object re-resolves against a live cache, and never
97
- * 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.
98
100
  */
99
101
  private controlStorage;
100
102
  /**
@@ -150,6 +152,14 @@ export declare class CadreNode implements SAppIdLookup {
150
152
  private controlBringUpInFlight;
151
153
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
152
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;
153
163
  /**
154
164
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
155
165
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
@@ -277,37 +287,89 @@ export declare class CadreNode implements SAppIdLookup {
277
287
  */
278
288
  private readonly crossPartyStrandAddrs;
279
289
  /**
280
- * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
281
- * this node has applied, keyed by peer id, valued by the seed's multiaddr
282
- * strings (parsed lazily, at dial time). Written by
283
- * {@link recordSeedBootstrapPeers}; read only by
284
- * {@link dialColdStartBootstrap} while the control database still has no
285
- * siblings to dial.
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.
286
342
  *
287
343
  * A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
288
344
  * targets must outlive the process: a node seeded into a party it could not
289
345
  * reach has nothing else on disk naming that party's addresses (`applySeed`
290
346
  * writes no control row, and `CadrePeer` fills in only after a connection
291
347
  * succeeds), so an in-memory-only set left it stranded permanently across a
292
- * 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
293
352
  * {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
294
353
  * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
295
354
  * node instance — same lifecycle as {@link trustedOwnerStore}. Durability
296
- * depends on the injected backend: Node gets a file-backed store from the CLI,
297
- * RN/browser stay ephemeral for now.
355
+ * depends on the injected backend; the default is in-memory.
298
356
  *
299
357
  * Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
300
358
  * reads for the steady-state path. The peer store is shared with everything
301
359
  * libp2p discovers, so "dial every entry" would grow into dialing arbitrary
302
360
  * discovered peers as the node lives longer; this store holds exactly the peers
303
- * an owner-signed, trust-anchored seed nominated as owners. `applySeed` merges
304
- * the same addresses into the peer store as well, so the two never disagree —
305
- * 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}).
306
366
  *
307
- * 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
308
368
  * owner's address changes replaces the stale address instead of accumulating
309
- * dead ones. Entries are never evicted: they are the node's only way back into
310
- * 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.
311
373
  */
312
374
  private bootstrapPeerStore;
313
375
  /**
@@ -365,6 +427,19 @@ export declare class CadreNode implements SAppIdLookup {
365
427
  * both read "no row yet" and race a duplicate INSERT (a `CadrePeer` PK conflict).
366
428
  */
367
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;
368
443
  /**
369
444
  * Re-replication queue for owner `authorizePeer` writes that committed while
370
445
  * this node was alone — `controlNode.getConnections().length === 0` at write
@@ -398,6 +473,14 @@ export declare class CadreNode implements SAppIdLookup {
398
473
  * exactly a "removals from before this lifetime" case).
399
474
  */
400
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;
401
484
  /** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
402
485
  private pendingSelfPeerWrite;
403
486
  /** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
@@ -488,6 +571,23 @@ export declare class CadreNode implements SAppIdLookup {
488
571
  * Get all strand instances
489
572
  */
490
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>;
491
591
  /**
492
592
  * Get a specific strand instance
493
593
  */
@@ -596,10 +696,10 @@ export declare class CadreNode implements SAppIdLookup {
596
696
  */
597
697
  getEnrolledMachineStore(): EnrolledMachineStore | null;
598
698
  /**
599
- * The node-local cold-start bootstrap-peer store (null before {@link start}) —
600
- * the retained owner addresses {@link reconcileControlCohort}'s cold-start
601
- * branch re-dials. Exposed for diagnostics and for a host that wants to show
602
- * "what would this node dial if it is stranded?".
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?".
603
703
  */
604
704
  getBootstrapPeerStore(): BootstrapPeerStore | null;
605
705
  /**
@@ -695,9 +795,8 @@ export declare class CadreNode implements SAppIdLookup {
695
795
  * operator whose relay slot is decorative may genuinely want only the announced
696
796
  * address, so this reports and proceeds.
697
797
  *
698
- * Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
699
- * hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
700
- * longer route — is caught too.
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.
701
800
  *
702
801
  * NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
703
802
  * diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
@@ -972,6 +1071,55 @@ export declare class CadreNode implements SAppIdLookup {
972
1071
  registerSelf(): Promise<SelfRegistrationOutcome>;
973
1072
  /** The body of {@link registerSelf}; serialised by its single-flight guard. */
974
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;
975
1123
  /**
976
1124
  * Sign this node's address record for publication, stamped strictly later than
977
1125
  * `existing` (the row it is about to replace, or null for a first insert) so a
@@ -1076,7 +1224,7 @@ export declare class CadreNode implements SAppIdLookup {
1076
1224
  * Applied by ALL THREE sources of control-dial candidates, so no call site
1077
1225
  * invents its own rule and none can produce a mixed list: the signed record
1078
1226
  * ({@link resolvePeerAddrs}), the libp2p address book
1079
- * ({@link peerStoreAddrs}), and a seed's retained owner addresses
1227
+ * ({@link peerStoreAddrs}), and a retained out-of-band dial target
1080
1228
  * ({@link bootstrapDialAddrs}).
1081
1229
  *
1082
1230
  * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
@@ -1089,14 +1237,14 @@ export declare class CadreNode implements SAppIdLookup {
1089
1237
  * way, never thrown, so every caller's list-shaping stays total.
1090
1238
  *
1091
1239
  * Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
1092
- * seed-supplied bootstrap addr) and to normalize the ones it ANNOUNCES
1240
+ * retained bootstrap addr) and to normalize the ones it ANNOUNCES
1093
1241
  * ({@link getStrandMultiaddrs}); the rule is the same either way — an address
1094
1242
  * that does not reach `peerId` is not an address for `peerId`.
1095
1243
  *
1096
1244
  * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
1097
1245
  * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
1098
1246
  * above parsed it already) and for our own node's id, reachable for a
1099
- * seed-supplied one ({@link bootstrapDialAddrs}).
1247
+ * retained one ({@link bootstrapDialAddrs}).
1100
1248
  */
1101
1249
  private bindAddrToPeer;
1102
1250
  /** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
@@ -1110,13 +1258,32 @@ export declare class CadreNode implements SAppIdLookup {
1110
1258
  * `self:peer:update` (all wired in {@link startRecordRefresh}).
1111
1259
  *
1112
1260
  * Concurrent triggers collapse into a single in-flight pass
1113
- * (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.
1114
1263
  * Best-effort throughout: a failure to resolve/dial any one sibling is logged
1115
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.
1116
1270
  */
1117
- reconcileControlCohort(): Promise<void>;
1271
+ reconcileControlCohort(): Promise<ControlCohortReconcileResult>;
1118
1272
  /** Body of {@link reconcileControlCohort}; serialised by its single-flight guard. */
1119
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;
1120
1287
  /**
1121
1288
  * Resolve every sibling's signed address record once and merge what resolves
1122
1289
  * into the control node's libp2p **address book** (peerStore). Returns the
@@ -1136,6 +1303,9 @@ export declare class CadreNode implements SAppIdLookup {
1136
1303
  * resolves to nothing (revoked, stale, untrusted) is not written at all, so its
1137
1304
  * existing entry ages out on its own.
1138
1305
  *
1306
+ * The same resolution also keeps a sibling's retained out-of-band dial target
1307
+ * current ({@link refreshDialHint}).
1308
+ *
1139
1309
  * NOTE: this resolves EVERY sibling serially before the dial loop below runs,
1140
1310
  * so it costs one record query per sibling per reconcile pass (~15s) and each
1141
1311
  * one delays the pass's first dial. A cadre is a handful of devices, so today
@@ -1143,6 +1313,28 @@ export declare class CadreNode implements SAppIdLookup {
1143
1313
  * into one query, merge only on change, or move the warm pass after the dials.
1144
1314
  */
1145
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;
1146
1338
  /**
1147
1339
  * {@link resolvePeerAddrs} for one sibling, best-effort: a control-DB read
1148
1340
  * failure yields `[]` (and the cold-start fallback then gets its turn) rather
@@ -1150,37 +1342,78 @@ export declare class CadreNode implements SAppIdLookup {
1150
1342
  */
1151
1343
  private resolveSiblingAddrs;
1152
1344
  /**
1153
- * 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
1154
1347
  * ({@link dialControlSibling}) and cold-start bootstrap peer
1155
- * ({@link dialBootstrapPeer}) alike, since both feed a whole address list to
1156
- * 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`.
1157
1351
  *
1158
- * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why the pass bounds
1159
- * 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.
1160
1355
  */
1161
- private controlDialTimeoutMs;
1356
+ private controlDialBudget;
1162
1357
  /**
1163
1358
  * Dial one sibling from its already-resolved addresses, best-effort. Returns
1164
- * whether a dial was attempted (false when no address resolves).
1359
+ * whether the dial resolved (false when no address resolves or the dial fails).
1165
1360
  *
1166
1361
  * A per-peer failure (NAT, offline, relay down, connection-gater denial, or
1167
- * the {@link controlDialTimeoutMs} budget expiring) is logged and swallowed so
1168
- * 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
1169
1364
  * {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
1170
1365
  * simply retried on the next pass.
1171
1366
  */
1172
1367
  private dialControlSibling;
1173
1368
  /**
1174
- * A sibling's control-network dial addresses for the reconcile pass.
1175
- *
1176
- * Primary (steady state): the signed, fresh, trust-gated control addresses
1177
- * `resolved` for it by {@link warmSiblingAddrBook} — passed in rather than
1178
- * re-resolved, so the pass makes one record query per sibling. Fallback (cold
1179
- * start): the libp2p peerStore entries `applySeed` populated, used only while
1180
- * the record is not yet resolvable. Returns `[]` (never throws) when neither
1181
- * 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.
1182
1397
  */
1183
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;
1184
1417
  /**
1185
1418
  * Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
1186
1419
  * {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
@@ -1213,15 +1446,33 @@ export declare class CadreNode implements SAppIdLookup {
1213
1446
  * signature-checked against a trust-anchored signer before this runs, and the
1214
1447
  * flag only *selects a dial target*; a dial grants no authority.
1215
1448
  *
1216
- * Self is excluded. `createSeed` projects EVERY `CadrePeer` row, so an owner
1217
- * that applies a seed minted after it joined finds itself in the owner list;
1218
- * retaining that would make the cold-start pass dial this node forever (the
1219
- * steady-state pass filters self out of its sibling list for the same reason).
1449
+ * Addressless peers and self are skipped by {@link retainDialTarget}.
1220
1450
  */
1221
1451
  private recordSeedBootstrapPeers;
1222
1452
  /**
1223
- * Cold-start branch of the reconcile pass: re-dial the seed's owner peers
1224
- * 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.
1225
1476
  *
1226
1477
  * `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
1227
1478
  * When that single dial fails — owner momentarily down, relay reservation not
@@ -1244,13 +1495,14 @@ export declare class CadreNode implements SAppIdLookup {
1244
1495
  */
1245
1496
  private dialColdStartBootstrap;
1246
1497
  /**
1247
- * Bind a bootstrap peer's seed addresses to its peer id, so the dial
1248
- * 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.
1249
1501
  *
1250
1502
  * The same {@link normalizeDialAddrs} rule the resolved and address-book paths
1251
1503
  * use, on the third and last source of control-dial candidates: an address that
1252
1504
  * already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
1253
- * (a seed that disagrees with itself is not a dial target); one that does not —
1505
+ * (an entry that disagrees with itself is not a dial target); one that does not —
1254
1506
  * a bare listen addr, or a relay hop with the destination missing — gets the id
1255
1507
  * encapsulated; an unencapsulatable one is dropped rather than dialed bare.
1256
1508
  */
@@ -1508,6 +1760,15 @@ export declare class CadreNode implements SAppIdLookup {
1508
1760
  * `control:disconnected` emit, which a never-connected start has no edge for.
1509
1761
  */
1510
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;
1511
1772
  private handleStrandIdle;
1512
1773
  /**
1513
1774
  * Hibernate a strand: release its strand-network resources via the strand
@@ -1601,13 +1862,32 @@ export declare class CadreNode implements SAppIdLookup {
1601
1862
  *
1602
1863
  * A rejected call leaves nothing running but DOES leave the sApp config
1603
1864
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1604
- * automatic relaunch need it. So once the strand's row is visible on the control
1605
- * network (via {@link publishStrand} or another member), a failed launch keeps
1606
- * being re-attempted in the background — each failure re-emitting `strand:error`
1607
- * — until it succeeds. {@link detachStrand} (reached via {@link stopStrand}) is
1608
- * 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.
1609
1876
  */
1610
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>;
1611
1891
  /**
1612
1892
  * Publish a strand row to the shared control database under this node's own
1613
1893
  * owner identity, so other cadre members discover it via control-network
@@ -1652,10 +1932,16 @@ export declare class CadreNode implements SAppIdLookup {
1652
1932
  * Failing loudly here is intentional: a silently-unpublished strand would run
1653
1933
  * as a local-only island that no peer could ever discover or join.
1654
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
+ *
1655
1941
  * @param strandId - Unique strand identifier (typically the same id passed to
1656
1942
  * {@link addStrand}).
1657
1943
  * @param type - `'o'` for open (default) or `'c'` for closed.
1658
- * @param memberPrivateKey - Optional membership key for a closed strand.
1944
+ * @param memberPrivateKey - Optional shared membership (read) key for a closed strand.
1659
1945
  * @returns The live `Strand` row — the one just inserted, or the matching one already
1660
1946
  * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
1661
1947
  * on a repeat it is the stored key, not the argument.
@@ -1725,10 +2011,12 @@ export declare class CadreNode implements SAppIdLookup {
1725
2011
  * is an owner-signed control-plane write like every other, not "destroy the network".
1726
2012
  *
1727
2013
  * Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
1728
- * this party's membership secret for that network, and it is stored nowhere else.
1729
- * With it gone the party can never again admit a member to that closed strand, and a
1730
- * re-published row would carry a DIFFERENT key that does not match the membership
1731
- * 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
1732
2020
  * blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
1733
2021
  * stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
1734
2022
  * `Revocation` tombstone names the id, which the consent branch of
@@ -1768,6 +2056,55 @@ export declare class CadreNode implements SAppIdLookup {
1768
2056
  * there throws over a completed removal.
1769
2057
  */
1770
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;
1771
2108
  /**
1772
2109
  * Publish an owner-signed `FormationInvite` (open-invitation token) to the
1773
2110
  * shared control database, so a later {@link formStrand} redemption can be
@@ -1882,8 +2219,17 @@ export declare class CadreNode implements SAppIdLookup {
1882
2219
  * now the tracked instance is founded in place
1883
2220
  * ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
1884
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).
1885
2226
  */
1886
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;
1887
2233
  /**
1888
2234
  * Resolve a strand's discovery seed — the dialable strand-network multiaddr
1889
2235
  * strings for cohort siblings. Membership comes from the control network's
@@ -1983,10 +2329,10 @@ export declare class CadreNode implements SAppIdLookup {
1983
2329
  * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
1984
2330
  * per-peer failure to `[]` and reports no per-peer success, and threading
1985
2331
  * success out would change its API for little gain. A failed INITIAL announce
1986
- * is fatal-at-start anyway (the relay denies the reservation,
1987
- * `libp2p.start()` throws, and wake/check-in re-resume retries with a fresh
1988
- * announce); a failed REFRESH retries within `DELEGATE_GRANT_TTL_MS / 2`
1989
- * (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.
1990
2336
  */
1991
2337
  private recordDelegateAnnounces;
1992
2338
  /**
@@ -2003,6 +2349,37 @@ export declare class CadreNode implements SAppIdLookup {
2003
2349
  * target, which must not stack up per strand.
2004
2350
  */
2005
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;
2006
2383
  /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
2007
2384
  private announceDelegateToDueRelays;
2008
2385
  /**
@@ -2087,22 +2464,32 @@ export declare class CadreNode implements SAppIdLookup {
2087
2464
  /**
2088
2465
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
2089
2466
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
2090
- * intact, so on the next node restart (or watcher poll) the strand is rediscovered and
2091
- * 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}.
2092
2473
  */
2093
2474
  stopStrand(strandId: string): Promise<void>;
2094
2475
  /**
2095
2476
  * Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
2096
- * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config, stop
2097
- * the instance, emit `strand:stopped`. Touches no control-plane row — which side of the
2098
- * 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.
2099
2481
  *
2100
2482
  * The stop + emit are skipped when the strand manager holds no instance for `strandId` —
2101
2483
  * e.g. a party owner that published a strand's row but never ran it locally, or an
2102
2484
  * explicit {@link stopStrand} for an id this node never started. `hibernationManager`
2103
- * untrack and the `sAppConfigs` delete stay unconditional (both are no-ops when there is
2104
- * nothing to remove), so a launch that failed before an instance was ever tracked still
2105
- * 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.
2106
2493
  */
2107
2494
  private detachStrand;
2108
2495
  /**
@@ -2209,6 +2596,11 @@ export declare class CadreNode implements SAppIdLookup {
2209
2596
  * relay indistinguishable from a hung start, and much shorter would fail nodes
2210
2597
  * on links that were merely slow. The retries carry on in the background after
2211
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).
2212
2604
  */
2213
2605
  private driveControlRelayReservation;
2214
2606
  /**
@@ -2573,6 +2965,20 @@ export declare class CadreNode implements SAppIdLookup {
2573
2965
  /**
2574
2966
  * Add a drone to the cadre (for phone/server adding provider-hosted node).
2575
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.
2576
2982
  */
2577
2983
  addDrone(options: AddDroneOptions): Promise<DroneInitResult>;
2578
2984
  /**
@@ -2629,6 +3035,78 @@ export declare class CadreNode implements SAppIdLookup {
2629
3035
  * @returns The member key and strand info if successful
2630
3036
  */
2631
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;
2632
3110
  /**
2633
3111
  * Remember the responder's strand-network addresses for a strand this node just
2634
3112
  * formed, so the strand's discovery seed has something to work with when the app