@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
@@ -8,6 +8,10 @@ import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WIND
8
8
  import { sign } from '@optimystic/quereus-plugin-crypto';
9
9
  import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
10
10
  import { strandTransportKey } from './strand-transport-key.js';
11
+ import { controlStorageScope } from './storage-scope.js';
12
+ import { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
13
+ import { assertNotPreSplitStrand, issueInvite, PreSplitStrandIdentityError } from './strand-membership-writer.js';
14
+ import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
11
15
  import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
12
16
  import { loadOrCreateIdentityKey } from './identity-key.js';
13
17
  import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
@@ -16,14 +20,14 @@ import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
16
20
  import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
17
21
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
18
22
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
19
- import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
23
+ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
20
24
  import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
21
25
  import { StrandWatcher } from './strand-watcher.js';
22
- import { StrandInstanceManager } from './strand-instance-manager.js';
26
+ import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manager.js';
23
27
  import { PeerJoinBackfill } from './peer-join-backfill.js';
24
28
  import { deriveCohortMembers } from './strand-cohort.js';
25
- import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
26
- import { withDeadline } from './control-stream.js';
29
+ import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
30
+ import { dialPeerAddrs, DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS, DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS } from './peer-dial.js';
27
31
  import { EnrollmentService } from './enrollment.js';
28
32
  import { HibernationManager } from './hibernation-manager.js';
29
33
  import { ControlDatabase, isStrandIdConflict } from './control-database.js';
@@ -40,6 +44,7 @@ import { superviseRelayReservation, resolveRelayReservationState } from './relay
40
44
  import { PushFanoutService } from './push-fanout.js';
41
45
  import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
42
46
  import { TurnRelayTracker } from './diagnostics/webrtc-turn-tracker.js';
47
+ import { timedStep } from './timed-step.js';
43
48
  const log = debug('sereus:cadre:node');
44
49
  const timing = debug('sereus:cadre:timing');
45
50
  /**
@@ -213,10 +218,11 @@ export class CadreNode {
213
218
  this._running = false;
214
219
  /**
215
220
  * The control database's own raw storage (cache-wrapped), resolved once per
216
- * `start()` and released in {@link cleanup}. Owning it is what keeps
217
- * `config.storage.provider('control')` a once-per-runtime call: a `stop()` then
218
- * `start()` cycle on this object re-resolves against a live cache, and never
219
- * orphans the previous wrapper's registration in the shared cache pool.
221
+ * `start()` and released in {@link cleanup}. Owning it is what keeps the
222
+ * provider call for the control scope — `controlStorageScope(partyId)`, see
223
+ * `storage-scope.ts` — a once-per-runtime call: a `stop()` then `start()` cycle
224
+ * on this object re-resolves against a live cache, and never orphans the
225
+ * previous wrapper's registration in the shared cache pool.
220
226
  */
221
227
  this.controlStorage = null;
222
228
  /**
@@ -272,6 +278,14 @@ export class CadreNode {
272
278
  this.controlBringUpInFlight = false;
273
279
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
274
280
  this.sAppConfigs = new Map();
281
+ /**
282
+ * Strands the control network advertises that no local sAppConfig claims — the
283
+ * backlog behind `strand:discovered`. Maintained in {@link handleStrandAdded}
284
+ * (added), {@link addStrand} (claimed), {@link detachStrand} (stopped or its
285
+ * control row vanished) and {@link cleanup}. Read through
286
+ * {@link getDiscoveredStrands}.
287
+ */
288
+ this.discoveredStrands = new Map();
275
289
  /**
276
290
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
277
291
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
@@ -388,37 +402,89 @@ export class CadreNode {
388
402
  */
389
403
  this.crossPartyStrandAddrs = new Map();
390
404
  /**
391
- * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
392
- * this node has applied, keyed by peer id, valued by the seed's multiaddr
393
- * strings (parsed lazily, at dial time). Written by
394
- * {@link recordSeedBootstrapPeers}; read only by
395
- * {@link dialColdStartBootstrap} while the control database still has no
396
- * siblings to dial.
405
+ * PENDING strand membership invitations learned at formation, keyed by strandId — the
406
+ * single-use `Strand.Invite` credential a closed-strand formation result carried back
407
+ * ({@link FormStrandResult.membershipInvite}), waiting for this node's strand bring-up
408
+ * to redeem (`consumeInvite` seats the `Strand.Member` row under this party's own key
409
+ * — the `strand-node-binds-member-peer` half of the party-identity chain).
410
+ *
411
+ * IN-MEMORY, deliberately, like {@link crossPartyStrandAddrs}: a restarted joiner
412
+ * re-forms from scratch anyway (durability is
413
+ * `backlog/feat-cross-party-strand-addr-durability`'s problem), and re-forming issues
414
+ * a fresh invitation. A re-formation against the same strand REPLACES the entry — the
415
+ * fresh invitation supersedes one that may have expired. The party key the invitation
416
+ * admits, by contrast, IS persisted (`StrandPartyKey`, seated by
417
+ * {@link adoptFormationMembershipInvite} before the entry lands here), so a lost
418
+ * invitation never orphans an identity.
419
+ *
420
+ * INVALIDATION is owned by the strand's bring-up membership reconciler
421
+ * (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
422
+ * in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
423
+ * against an already-seated member, or found dead (expired / cancelled / consumed
424
+ * elsewhere / the strand sealed). Until the strand is actually launched here the entry
425
+ * just waits.
426
+ *
427
+ * NOTE: entries live for the node's lifetime (one small pair per formed closed
428
+ * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
429
+ * if a node ever forms strands at scale, evict alongside that map.
430
+ */
431
+ this.pendingMembershipInvites = new Map();
432
+ /**
433
+ * Founder launches refused because the strand was founded before the per-party
434
+ * identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
435
+ * {@link issueStrandMembershipInvite}, which rethrows the recorded error so a bound
436
+ * redemption against the strand is rejected as "must be recreated" — not told to retry
437
+ * because the runtime is not live (a refused fresh launch leaves none) or failed by the
438
+ * `Strand.Invite` gate (a refused in-place founding leaves a joiner instance up whose
439
+ * party key is no manager).
440
+ *
441
+ * Written and cleared by {@link launchStrand}; also cleared by {@link detachStrand},
442
+ * {@link unpublishStrand} and {@link cleanup}, so a recreated id starts clean.
443
+ * In-memory: after a restart the next founder launch of the strand records it again;
444
+ * until then issuance still reads the fingerprint off the live rows whenever a runtime
445
+ * is up — only a strand with no runtime at all answers retryably meanwhile.
446
+ */
447
+ this.strandLaunchRefusals = new Map();
448
+ /**
449
+ * Dial targets learned out of band, keyed by peer id, valued by the multiaddr
450
+ * strings this node was handed (parsed lazily, at dial time). Two writers:
451
+ * {@link recordSeedBootstrapPeers} retains the owner-flagged peers of every seed
452
+ * this node applies, and {@link addDrone} retains the addresses of every node
453
+ * this node adds. Two readers: {@link dialColdStartBootstrap} dials every entry
454
+ * while the control database still has no siblings, and
455
+ * {@link resolveControlDialAddrs} falls back to a sibling's entry when neither
456
+ * its signed record nor the address book yields an address.
397
457
  *
398
458
  * A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
399
459
  * targets must outlive the process: a node seeded into a party it could not
400
460
  * reach has nothing else on disk naming that party's addresses (`applySeed`
401
461
  * writes no control row, and `CadrePeer` fills in only after a connection
402
462
  * succeeds), so an in-memory-only set left it stranded permanently across a
403
- * restart. Constructed (or adopted from `config.bootstrapPeers.store`) by
463
+ * restart. An owner that added a node it cannot be dialed by is in the same
464
+ * position: the added node's row stays unsigned, so unresolvable, until that
465
+ * node self-publishes over a connection only the owner can open. Constructed (or
466
+ * adopted from `config.bootstrapPeers.store`) by
404
467
  * {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
405
468
  * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
406
469
  * node instance — same lifecycle as {@link trustedOwnerStore}. Durability
407
- * depends on the injected backend: Node gets a file-backed store from the CLI,
408
- * RN/browser stay ephemeral for now.
470
+ * depends on the injected backend; the default is in-memory.
409
471
  *
410
472
  * Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
411
473
  * reads for the steady-state path. The peer store is shared with everything
412
474
  * libp2p discovers, so "dial every entry" would grow into dialing arbitrary
413
475
  * discovered peers as the node lives longer; this store holds exactly the peers
414
- * an owner-signed, trust-anchored seed nominated as owners. `applySeed` merges
415
- * the same addresses into the peer store as well, so the two never disagree —
416
- * this one is just the precisely-scoped subset.
476
+ * an owner-signed, trust-anchored seed nominated as owners, and the nodes this
477
+ * node chose to add. `applySeed` also merges a seed's addresses into the peer
478
+ * store, where they age out; an added node's addresses are never merged there,
479
+ * because only verified addresses go into the address book (see
480
+ * {@link warmSiblingAddrBook}).
417
481
  *
418
- * A later seed OVERWRITES an entry rather than merging, so a re-seed after an
482
+ * A later record OVERWRITES an entry rather than merging, so a re-seed after an
419
483
  * owner's address changes replaces the stale address instead of accumulating
420
- * dead ones. Entries are never evicted: they are the node's only way back into
421
- * the party if it is ever stranded again.
484
+ * dead ones, and {@link warmSiblingAddrBook} replaces an entry with the addresses
485
+ * the sibling's signed record resolves to when they differ. Only this node's own
486
+ * {@link removePeer} evicts an entry: otherwise they are the node's only way back
487
+ * to those peers if it is ever stranded again.
422
488
  */
423
489
  this.bootstrapPeerStore = null;
424
490
  /**
@@ -458,6 +524,19 @@ export class CadreNode {
458
524
  * both read "no row yet" and race a duplicate INSERT (a `CadrePeer` PK conflict).
459
525
  */
460
526
  this.registerSelfInFlight = null;
527
+ /**
528
+ * When this node last PUBLISHED its own `CadrePeer` address record (inserted or
529
+ * refreshed), or null if it never has in this session. Stamped by
530
+ * {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
531
+ * from — the boot pass, the heartbeat, an address change, a drain or an explicit call.
532
+ *
533
+ * Read by {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
534
+ * attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
535
+ * machine in the party is already discarding this node's address as stale.
536
+ */
537
+ this.lastSelfRecordPublishAt = null;
538
+ /** Say-once latch for {@link escalateIfSelfRecordStale}; re-armed by the next publish. */
539
+ this.selfRecordStaleWarned = false;
461
540
  // ── Write-while-alone re-replication (control-write-ensure-replicated) ──────
462
541
  /**
463
542
  * Re-replication queue for owner `authorizePeer` writes that committed while
@@ -492,6 +571,14 @@ export class CadreNode {
492
571
  * exactly a "removals from before this lifetime" case).
493
572
  */
494
573
  this.reissuedHeldRevocations = false;
574
+ /**
575
+ * Whether this process has seen the singleton `Revocation` ledger marker filed — its own
576
+ * `'opened'` or an `'already-open'` answer, in {@link openRevocationLedgerIfDue}. A
577
+ * record of work done, not a cached authorization answer: the marker can never be
578
+ * deleted (`NoDelete`), so this can only go stale if the database itself is replaced,
579
+ * which is why stop() clears it with the other per-process replication state.
580
+ */
581
+ this.revocationLedgerOpened = false;
495
582
  /** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
496
583
  this.pendingSelfPeerWrite = false;
497
584
  /** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
@@ -627,6 +714,25 @@ export class CadreNode {
627
714
  getStrands() {
628
715
  return this.strandManager.getInstances();
629
716
  }
717
+ /**
718
+ * Strands the control network advertises that no local sAppConfig claims —
719
+ * the backlog behind `strand:discovered`.
720
+ *
721
+ * The event fires once per strand, and it can fire before the app has
722
+ * subscribed (the watcher's first poll runs inside `start()`). So an app that
723
+ * auto-joins discovered strands must subscribe FIRST and then drain this map,
724
+ * not rely on the event alone. Entries leave the map when the strand is
725
+ * claimed ({@link addStrand}) or its control row disappears.
726
+ *
727
+ * Bounded by the number of strands this party has that this node does not run
728
+ * — the control database holds only this party's rows — so there is no cap and
729
+ * no eviction policy to reason about.
730
+ *
731
+ * Returns a snapshot; mutating it does not affect the node.
732
+ */
733
+ getDiscoveredStrands() {
734
+ return new Map(this.discoveredStrands);
735
+ }
630
736
  /**
631
737
  * Get a specific strand instance
632
738
  */
@@ -742,6 +848,9 @@ export class CadreNode {
742
848
  // Committed-delete seam → write-while-alone revocation queue. Same wiring
743
849
  // window argument as the membership listener above.
744
850
  this.controlDatabase.setGuardedDeleteListener((revocation) => this.noteGuardedDelete(revocation));
851
+ // Abandoned-write seam. Wired here, before the first control write can run, so a
852
+ // write lost during the rest of bring-up is reported like any other.
853
+ this.controlDatabase.setControlWriteAbandonedListener((abandonment) => this.noteControlWriteAbandoned(abandonment));
745
854
  // Create strand queryable using the control database
746
855
  const queryable = this.createStrandQueryable();
747
856
  // Create and start the strand watcher with sAppId lookup
@@ -808,7 +917,7 @@ export class CadreNode {
808
917
  // scheduleSelfRegistration, so this node's first published `CadrePeer` row
809
918
  // already carries the `/p2p-circuit` address the reservation earns it.
810
919
  // Throws on a relay that will not have us — start() is fail-fast for a node
811
- // whose operator named a relay.
920
+ // whose operator named a relay, unless network.requireRelay is false.
812
921
  await this.driveControlRelayReservation();
813
922
  // Schedule self-registration in background
814
923
  this.scheduleSelfRegistration();
@@ -1025,10 +1134,10 @@ export class CadreNode {
1025
1134
  return this.enrolledMachineStore;
1026
1135
  }
1027
1136
  /**
1028
- * The node-local cold-start bootstrap-peer store (null before {@link start}) —
1029
- * the retained owner addresses {@link reconcileControlCohort}'s cold-start
1030
- * branch re-dials. Exposed for diagnostics and for a host that wants to show
1031
- * "what would this node dial if it is stranded?".
1137
+ * The node-local bootstrap-peer store (null before {@link start}) — the dial
1138
+ * targets learned out of band that {@link reconcileControlCohort} falls back to.
1139
+ * Exposed for diagnostics and for a host that wants to show "what would this
1140
+ * node dial if it is stranded?".
1032
1141
  */
1033
1142
  getBootstrapPeerStore() {
1034
1143
  return this.bootstrapPeerStore;
@@ -1180,8 +1289,12 @@ export class CadreNode {
1180
1289
  if (!provider) {
1181
1290
  return undefined;
1182
1291
  }
1183
- const resolved = typeof provider === 'function' ? provider('control') : provider;
1184
- this.controlStorage = wrapStorageWithCache(resolved, 'control');
1292
+ // Party-scoped, not the bare literal 'control': the control database holds THIS
1293
+ // party's records, so two parties on one device must not land in one store.
1294
+ // Same key as the cache label, which also makes the shared pool's `stats()` readable.
1295
+ const scope = controlStorageScope(this.config.controlNetwork.partyId);
1296
+ const resolved = typeof provider === 'function' ? provider(scope) : provider;
1297
+ this.controlStorage = wrapStorageWithCache(resolved, scope);
1185
1298
  return this.controlStorage;
1186
1299
  }
1187
1300
  /**
@@ -1195,9 +1308,8 @@ export class CadreNode {
1195
1308
  * operator whose relay slot is decorative may genuinely want only the announced
1196
1309
  * address, so this reports and proceeds.
1197
1310
  *
1198
- * Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
1199
- * hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
1200
- * longer route — is caught too.
1311
+ * Keyed off `relayAddrs` AND `listenAddrs`, so a hand-written `/p2p-circuit` entry
1312
+ * in `listenAddrs` — a reservation by the longer route — is caught too.
1201
1313
  *
1202
1314
  * NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
1203
1315
  * diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
@@ -1209,9 +1321,13 @@ export class CadreNode {
1209
1321
  if (!replacesAdvertisedAddrs(network)) {
1210
1322
  return;
1211
1323
  }
1212
- const listensOnCircuit = (resolveListenAddrs(network) ?? [])
1213
- .some((addr) => addr.includes('/p2p-circuit'));
1214
- if (!listensOnCircuit) {
1324
+ // Read off the raw config, not the resolved listen set: the question is only
1325
+ // "does this config name a relay", and the resolution throws on a hand-written
1326
+ // `<relay>/p2p-circuit` listen entry — which still warrants this warning, and
1327
+ // gets its own refusal a few lines later in `buildControlNodeOptions`.
1328
+ const namesRelay = (network?.relayAddrs?.length ?? 0) > 0
1329
+ || (network?.listenAddrs ?? []).some((addr) => addr.includes('/p2p-circuit'));
1330
+ if (!namesRelay) {
1215
1331
  return;
1216
1332
  }
1217
1333
  console.warn('network.announceAddrs is set alongside a circuit-relay listener (from network.relayAddrs, ' +
@@ -1231,11 +1347,11 @@ export class CadreNode {
1231
1347
  buildControlNodeOptions() {
1232
1348
  const { controlNetwork, network, profile } = this.config;
1233
1349
  const identityKey = this.identityKey;
1234
- // `'search'`: `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH
1235
- // entry, which opens no connection — the reservation is driven explicitly at
1236
- // the END of `start()`, so the control database is built while this node holds
1237
- // zero control connections. See `relay-addrs.ts` and `start()`.
1238
- const listenAddrs = resolveListenAddrs(network, 'search');
1350
+ // `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH entry, which
1351
+ // opens no connection — the reservation is driven explicitly at the END of
1352
+ // `start()`, so the control database is built while this node holds zero
1353
+ // control connections. See `relay-addrs.ts` and `start()`.
1354
+ const listenAddrs = resolveListenAddrs(network);
1239
1355
  // The transports those listen entries imply. A `/ws` entry switches db-p2p's
1240
1356
  // WebSocket transport on (without it the entry binds nothing and libp2p reports
1241
1357
  // nothing); anything outside {tcp, ws/wss, p2p-circuit} throws here rather than
@@ -1809,6 +1925,7 @@ export class CadreNode {
1809
1925
  if (await this.seedBootstrapService.insertSelfPeerRecord(record)) {
1810
1926
  if (this.committedAlone())
1811
1927
  this.pendingSelfPeerWrite = true;
1928
+ this.noteSelfRecordPublished();
1812
1929
  log('registerSelf: inserted own CadrePeer record (owner-signed, updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
1813
1930
  return 'inserted';
1814
1931
  }
@@ -1846,9 +1963,81 @@ export class CadreNode {
1846
1963
  await this.controlDatabase.updateSelfPeerRecord(record);
1847
1964
  if (this.committedAlone())
1848
1965
  this.pendingSelfPeerWrite = true;
1966
+ this.noteSelfRecordPublished();
1849
1967
  log('registerSelf: refreshed own CadrePeer record (updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
1850
1968
  return 'refreshed';
1851
1969
  }
1970
+ /**
1971
+ * Record that this node's own address record just landed, and re-arm the stale-record
1972
+ * warning. Called from BOTH publishing paths of {@link publishSelfRecord} and nowhere
1973
+ * else — a `skipped` publish wrote nothing, so it must not refresh the stamp.
1974
+ */
1975
+ noteSelfRecordPublished() {
1976
+ this.lastSelfRecordPublishAt = Date.now();
1977
+ this.selfRecordStaleWarned = false;
1978
+ }
1979
+ /**
1980
+ * Report an abandoned control write to the embedding app.
1981
+ *
1982
+ * Every control write funnels through `ControlDatabase.lockedWithRetry`, which now tells
1983
+ * this node when it gives one up. Foreground writes ALSO reject to their caller; the
1984
+ * background ones ({@link startRecordRefresh}'s republish and the two replication
1985
+ * drains) are fired unawaited with a `debug`-only catch, so this event is the only thing
1986
+ * that reaches an app at all.
1987
+ *
1988
+ * Reporting only — the one operator-visible escalation lives on the republish path
1989
+ * ({@link escalateIfSelfRecordStale}), where the consequence is measurable.
1990
+ */
1991
+ noteControlWriteAbandoned(abandonment) {
1992
+ log('Control write abandoned [%s] after %d/%d attempt(s) in %dms (%s): %o', abandonment.label ?? 'unlabelled', abandonment.attemptsMade, abandonment.attemptsAllowed, abandonment.elapsedMs, abandonment.reason, abandonment.error);
1993
+ this.emit('control:write-abandoned', abandonment);
1994
+ }
1995
+ /**
1996
+ * Escalate ONE failed self-address republish to the operator — once — when the node has
1997
+ * not published its own record for longer than a record stays fresh.
1998
+ *
1999
+ * The bar is the CONSEQUENCE, not a failure count: a resolver discards a `CadrePeer`
2000
+ * record older than {@link DEFAULT_PEER_RECORD_MAX_AGE_MS} (15 minutes) and the heartbeat
2001
+ * re-stamps at half that, so by the time this gap opens every other machine in the party
2002
+ * has already stopped accepting this node's address. A count would fire far too early —
2003
+ * one failed heartbeat is already half the budget, and a single miss costs nothing.
2004
+ *
2005
+ * Say-once, re-armed by the next successful publish ({@link noteSelfRecordPublished}), so
2006
+ * a node that stays broken warns once rather than every 7.5 minutes.
2007
+ *
2008
+ * A node that has never published in this session is skipped: it has no record out there
2009
+ * to go stale, and the reason it has none (not a member yet, no signing key, revoked) is
2010
+ * already logged by `registerSelf` itself.
2011
+ *
2012
+ * Only a FAILED republish is checked. A heartbeat whose publish reports `skipped` neither
2013
+ * stamps nor warns, so a node that stops being able to publish without erroring — revoked,
2014
+ * or its row removed — goes quiet here. That is the intended reading: a revoked node has
2015
+ * nothing to publish and its unreachability is the point, not a fault to report.
2016
+ *
2017
+ * NOTE: the second `console.*` in this library, and the reason
2018
+ * {@link warnIfAnnounceAddrsDiscardRelay}'s note says a third should not simply be added:
2019
+ * both are operator warnings about a configuration/health condition an embedder cannot
2020
+ * see otherwise, and both have an event beside them (`control:write-abandoned` carries
2021
+ * the underlying write failure). A third such condition should surface through
2022
+ * {@link CadreNodeEvents} alone unless it likewise degrades the whole party in silence.
2023
+ */
2024
+ escalateIfSelfRecordStale(reason, error) {
2025
+ const publishedAt = this.lastSelfRecordPublishAt;
2026
+ if (publishedAt === null || this.selfRecordStaleWarned) {
2027
+ return;
2028
+ }
2029
+ const staleForMs = Date.now() - publishedAt;
2030
+ if (staleForMs < DEFAULT_PEER_RECORD_MAX_AGE_MS) {
2031
+ return;
2032
+ }
2033
+ this.selfRecordStaleWarned = true;
2034
+ console.warn(`[sereus] this machine has not published its own address record for ${Math.round(staleForMs / 60000)} `
2035
+ + `minutes. The latest attempt (${reason}) failed with: `
2036
+ + `${error instanceof Error ? error.message : String(error)}. `
2037
+ + 'Other machines in the party discard an address record older than '
2038
+ + `${Math.round(DEFAULT_PEER_RECORD_MAX_AGE_MS / 60000)} minutes, so any of them not already `
2039
+ + 'connected to this one can no longer reach it. Publishing keeps retrying; this is reported once.');
2040
+ }
1852
2041
  /**
1853
2042
  * Sign this node's address record for publication, stamped strictly later than
1854
2043
  * `existing` (the row it is about to replace, or null for a first insert) so a
@@ -1955,7 +2144,12 @@ export class CadreNode {
1955
2144
  return;
1956
2145
  }
1957
2146
  const republish = (reason) => {
1958
- void this.registerSelf().catch((error) => log('Record refresh (%s) failed: %o', reason, error));
2147
+ void this.registerSelf().catch((error) => {
2148
+ log('Record refresh (%s) failed: %o', reason, error);
2149
+ // The only failure in this file that degrades the whole party on its own, so it is
2150
+ // the only one that escalates past `debug`. See escalateIfSelfRecordStale.
2151
+ this.escalateIfSelfRecordStale(reason, error);
2152
+ });
1959
2153
  };
1960
2154
  const reconcile = (reason) => {
1961
2155
  void this.reconcileControlCohort().catch((error) => log('Control-cohort reconcile (%s) failed: %o', reason, error));
@@ -1973,7 +2167,10 @@ export class CadreNode {
1973
2167
  // `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
1974
2168
  // wired here are the only ones after that: `self:peer:update` needs an ADDRESS
1975
2169
  // CHANGE, and a relay-only node's addresses stop changing the moment its
1976
- // reservation lands — inside `start()`, before this listener exists. Harmless
2170
+ // reservation lands — inside `start()`, before this listener exists (on the
2171
+ // `network.requireRelay: false` posture the reservation may instead land LATER,
2172
+ // from the retry supervisor, and then this listener is what publishes the
2173
+ // `/p2p-circuit` address the boot-time publish could not carry). Harmless
1977
2174
  // where a late-authorized node's addresses keep churning (NAT/relay rotation
1978
2175
  // fire the event anyway), and invisible while parties authorize members before
1979
2176
  // they boot. If late enrollment becomes the normal path — an invited phone is
@@ -2035,10 +2232,16 @@ export class CadreNode {
2035
2232
  this.hasControlConnection = false;
2036
2233
  this.reconstructedLocalOnlyWrites = false;
2037
2234
  this.reissuedHeldRevocations = false;
2235
+ this.revocationLedgerOpened = false;
2038
2236
  this.pendingPeerWrites.clear();
2039
2237
  this.pendingRevocations.clear();
2040
2238
  this.pendingSelfPeerWrite = false;
2041
2239
  this.pendingSelfDeviceWrite = false;
2240
+ // Self-record publication history is per-session too: a restarted node re-publishes
2241
+ // from scratch, and carrying the previous lifetime's stamp forward would either
2242
+ // suppress the stale-record warning or fire it on a node that just came up.
2243
+ this.lastSelfRecordPublishAt = null;
2244
+ this.selfRecordStaleWarned = false;
2042
2245
  }
2043
2246
  /**
2044
2247
  * Resolve a peer's current, signed, trust-checkable multiaddrs from only its
@@ -2126,7 +2329,7 @@ export class CadreNode {
2126
2329
  * Applied by ALL THREE sources of control-dial candidates, so no call site
2127
2330
  * invents its own rule and none can produce a mixed list: the signed record
2128
2331
  * ({@link resolvePeerAddrs}), the libp2p address book
2129
- * ({@link peerStoreAddrs}), and a seed's retained owner addresses
2332
+ * ({@link peerStoreAddrs}), and a retained out-of-band dial target
2130
2333
  * ({@link bootstrapDialAddrs}).
2131
2334
  *
2132
2335
  * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
@@ -2153,14 +2356,14 @@ export class CadreNode {
2153
2356
  * way, never thrown, so every caller's list-shaping stays total.
2154
2357
  *
2155
2358
  * Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
2156
- * seed-supplied bootstrap addr) and to normalize the ones it ANNOUNCES
2359
+ * retained bootstrap addr) and to normalize the ones it ANNOUNCES
2157
2360
  * ({@link getStrandMultiaddrs}); the rule is the same either way — an address
2158
2361
  * that does not reach `peerId` is not an address for `peerId`.
2159
2362
  *
2160
2363
  * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
2161
2364
  * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
2162
2365
  * above parsed it already) and for our own node's id, reachable for a
2163
- * seed-supplied one ({@link bootstrapDialAddrs}).
2366
+ * retained one ({@link bootstrapDialAddrs}).
2164
2367
  */
2165
2368
  bindAddrToPeer(addr, peerId) {
2166
2369
  try {
@@ -2210,9 +2413,15 @@ export class CadreNode {
2210
2413
  * `self:peer:update` (all wired in {@link startRecordRefresh}).
2211
2414
  *
2212
2415
  * Concurrent triggers collapse into a single in-flight pass
2213
- * (see {@link reconcileControlCohortInFlight}) so two passes never double-dial.
2416
+ * (see {@link reconcileControlCohortInFlight}) so two passes never double-dial;
2417
+ * a call that joins an in-flight pass resolves to THAT pass's result.
2214
2418
  * Best-effort throughout: a failure to resolve/dial any one sibling is logged
2215
2419
  * and the pass continues; the whole pass is a no-op when the node is alone.
2420
+ *
2421
+ * Resolves to the peers the pass dialled ({@link ControlCohortReconcileResult}),
2422
+ * so a caller can tell a link this pass opened from one something else opened —
2423
+ * the pass skips a peer that is already connected. The timer and event triggers
2424
+ * discard it.
2216
2425
  */
2217
2426
  async reconcileControlCohort() {
2218
2427
  if (this.reconcileControlCohortInFlight) {
@@ -2221,7 +2430,7 @@ export class CadreNode {
2221
2430
  const op = this.runReconcileControlCohort();
2222
2431
  this.reconcileControlCohortInFlight = op;
2223
2432
  try {
2224
- await op;
2433
+ return await op;
2225
2434
  }
2226
2435
  finally {
2227
2436
  this.reconcileControlCohortInFlight = null;
@@ -2232,7 +2441,7 @@ export class CadreNode {
2232
2441
  // Shutdown / not-yet-started guard (mirrors publishSelfRecord). A pass that
2233
2442
  // fires after stop() began must early-return rather than touch a torn-down node.
2234
2443
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2235
- return;
2444
+ return { dialed: [] };
2236
2445
  }
2237
2446
  // Ride this pass's cadence to refresh the per-stream gate's materialized
2238
2447
  // authorized set — membership changes that ARRIVED BY REPLICATION (rather
@@ -2242,11 +2451,15 @@ export class CadreNode {
2242
2451
  // CadrePeer query (two reads per pass), plus a third from
2243
2452
  // `refreshStrandPeerAddrs` on the passes where a strand is due AND this node
2244
2453
  // holds a control connection (one read for the whole pass, not one per
2245
- // strand); if those reads ever get costly, share one row-set across all
2246
- // three.
2454
+ // strand). Each also reads Revocation first. Before the Revocation ledger
2455
+ // marker exists (filed below, once connected) that block is missing, so every
2456
+ // one of those reads consults the cohort about it; once the marker exists every
2457
+ // block they touch is held and none of them does (both states pinned in
2458
+ // control-founding-consult-budget.spec.ts). If those reads ever get costly,
2459
+ // share one row-set across all three.
2247
2460
  await this.refreshMembershipGate('reconcile');
2248
2461
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2249
- return;
2462
+ return { dialed: [] };
2250
2463
  }
2251
2464
  // Refresh relay delegate grants BEFORE the sibling enumeration below: a
2252
2465
  // solo cadre with a party relay has no siblings to dial but must still keep
@@ -2254,7 +2467,7 @@ export class CadreNode {
2254
2467
  // the reservation and faces the connection gate again).
2255
2468
  await this.refreshDelegateGrants();
2256
2469
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2257
- return;
2470
+ return { dialed: [] };
2258
2471
  }
2259
2472
  // Then re-warm each running strand's own address book from its siblings, on
2260
2473
  // its own (much longer) throttle. Same reasoning as warmSiblingAddrBook
@@ -2262,7 +2475,7 @@ export class CadreNode {
2262
2475
  // layer under cadre-core dials strand peers by bare peer id.
2263
2476
  await this.refreshStrandPeerAddrs();
2264
2477
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2265
- return;
2478
+ return { dialed: [] };
2266
2479
  }
2267
2480
  const selfPeerId = this.controlNode.peerId.toString();
2268
2481
  // Reap guarded rows this node still holds for incarnations an already-committed
@@ -2304,7 +2517,27 @@ export class CadreNode {
2304
2517
  log('reconcileControlCohort: reap pass failed (continuing): %o', error);
2305
2518
  }
2306
2519
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2307
- return;
2520
+ return { dialed: [] };
2521
+ }
2522
+ // File the Revocation ledger marker once, so that table stops being a never-written
2523
+ // block, which the storage layer re-checks with the cohort on every read (and every
2524
+ // membership lookup and guarded insert reads it). Connected-only for the reap's
2525
+ // reason above: a marker committed alone is local-only, and one filed by a
2526
+ // disconnected owner while another machine creates the same collection is exactly
2527
+ // that fork. Owner-only, and at most once per process — see openRevocationLedgerIfDue.
2528
+ //
2529
+ // NOTE: accepted tradeoff — a solo founder does not file the marker until it holds a
2530
+ // control connection (normally its first sibling; a relay or bootstrap connection also
2531
+ // counts — see getControlConnectionCount — and is the reap's same residual), so until
2532
+ // then it keeps paying one local findCluster per read of
2533
+ // the missing Revocation block (0.009 ms each, measured upstream; no network work on
2534
+ // a cohort of one). Fork safety weighed over that and kept; revisit if findCluster
2535
+ // ever shows up as material in a device profile, or if a solo-founding marker can be
2536
+ // made fork-safe (e.g. filed inside the genesis transaction, before any other machine
2537
+ // can hold the party's collections).
2538
+ await this.openRevocationLedgerIfDue();
2539
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
2540
+ return { dialed: [] };
2308
2541
  }
2309
2542
  }
2310
2543
  // 1. Enumerate known siblings. This membership read is itself a pull-on-read
@@ -2318,12 +2551,11 @@ export class CadreNode {
2318
2551
  // because filling this table needs a connection and getting a connection
2319
2552
  // needs this table. Fall back to the seed's bootstrap addresses; a solo
2320
2553
  // cadre has none and this is a no-op. Must not throw or busy-loop.
2321
- await this.dialColdStartBootstrap();
2322
- return;
2554
+ return { dialed: await this.dialColdStartBootstrap() };
2323
2555
  }
2324
2556
  // Re-guard after the await: a stop() may have raced the membership read.
2325
2557
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2326
- return;
2558
+ return { dialed: [] };
2327
2559
  }
2328
2560
  // 2. Classify backbone (owner) members and select a bounded dial set.
2329
2561
  // NOTE: deliberately the REPLICATED table, not the node-local anchor — this
@@ -2333,7 +2565,7 @@ export class CadreNode {
2333
2565
  // the anchor if owner status here ever gates something trusted.
2334
2566
  const ownerKeys = await this.controlDatabase.getOwnerKeys();
2335
2567
  if (!this._running || !this.controlNode) {
2336
- return;
2568
+ return { dialed: [] };
2337
2569
  }
2338
2570
  const targetDegree = this.config.network?.controlCohort?.targetDegree
2339
2571
  ?? DEFAULT_CONTROL_COHORT_TARGET_DEGREE;
@@ -2346,25 +2578,51 @@ export class CadreNode {
2346
2578
  // keeping what resolved for the dial loop below.
2347
2579
  const resolved = await this.warmSiblingAddrBook(siblings);
2348
2580
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2349
- return;
2581
+ return { dialed: [] };
2350
2582
  }
2351
2583
  // 4. Skip already-connected peers (no re-dial / churn for live connections).
2352
2584
  const connected = new Set(this.controlNode.getConnections().map((c) => c.remotePeer.toString()));
2353
2585
  // 5. Dial each selected, not-yet-connected sibling, best-effort, from the
2354
2586
  // addresses step 3 already resolved for it.
2355
- let dialed = 0;
2587
+ const dialed = [];
2356
2588
  for (const sibling of dials) {
2357
2589
  if (!this._running || !this.controlNode) {
2358
- return;
2590
+ return { dialed };
2359
2591
  }
2360
2592
  if (connected.has(sibling.peerId)) {
2361
2593
  continue;
2362
2594
  }
2363
2595
  if (await this.dialControlSibling(sibling, resolved.get(sibling.peerId) ?? [])) {
2364
- dialed++;
2596
+ dialed.push(sibling.peerId);
2365
2597
  }
2366
2598
  }
2367
- log('reconcileControlCohort: pass complete (siblings=%d, selected=%d, dialed=%d)', siblings.length, dials.length, dialed);
2599
+ log('reconcileControlCohort: pass complete (siblings=%d, selected=%d, dialed=%d)', siblings.length, dials.length, dialed.length);
2600
+ return { dialed };
2601
+ }
2602
+ /**
2603
+ * The owner-only half of the reconcile pass's ledger-marker step: file the singleton
2604
+ * `Revocation` marker ({@link SeedBootstrapService.openRevocationLedger}) unless this
2605
+ * process has already seen it filed. The connectivity gate is the caller's
2606
+ * ({@link runReconcileControlCohort}); why the marker exists is on
2607
+ * {@link ControlDatabase.openRevocationLedger}.
2608
+ *
2609
+ * Sets {@link revocationLedgerOpened} on `'opened'` or `'already-open'`. A node that
2610
+ * cannot sign as an owner does nothing: it picks the owner's marker up the first time it
2611
+ * reads the table, like any other replicated row. Best-effort like every step of the
2612
+ * pass — a failure is logged and leaves the flag clear, so the next connected pass retries.
2613
+ */
2614
+ async openRevocationLedgerIfDue() {
2615
+ if (this.revocationLedgerOpened || !this.seedBootstrapService?.canAuthorize()) {
2616
+ return;
2617
+ }
2618
+ try {
2619
+ const outcome = await this.seedBootstrapService.openRevocationLedger();
2620
+ this.revocationLedgerOpened = true;
2621
+ log('reconcileControlCohort: revocation ledger marker %s', outcome);
2622
+ }
2623
+ catch (error) {
2624
+ log('reconcileControlCohort: filing the revocation ledger marker failed (retrying next pass): %o', error);
2625
+ }
2368
2626
  }
2369
2627
  /**
2370
2628
  * Resolve every sibling's signed address record once and merge what resolves
@@ -2385,6 +2643,9 @@ export class CadreNode {
2385
2643
  * resolves to nothing (revoked, stale, untrusted) is not written at all, so its
2386
2644
  * existing entry ages out on its own.
2387
2645
  *
2646
+ * The same resolution also keeps a sibling's retained out-of-band dial target
2647
+ * current ({@link refreshDialHint}).
2648
+ *
2388
2649
  * NOTE: this resolves EVERY sibling serially before the dial loop below runs,
2389
2650
  * so it costs one record query per sibling per reconcile pass (~15s) and each
2390
2651
  * one delays the pass's first dial. A cadre is a handful of devices, so today
@@ -2394,6 +2655,9 @@ export class CadreNode {
2394
2655
  async warmSiblingAddrBook(siblings) {
2395
2656
  const resolved = new Map();
2396
2657
  const counts = { merged: 0, restamped: 0, skipped: 0, failed: 0 };
2658
+ // One copy of the store's map for the whole loop; a refresh replaces an entry
2659
+ // rather than mutating it, so the copy stays a consistent "before" view.
2660
+ const hints = this.bootstrapPeerStore?.all();
2397
2661
  for (const sibling of siblings) {
2398
2662
  if (!this._running || !this.controlNode) {
2399
2663
  break;
@@ -2406,6 +2670,7 @@ export class CadreNode {
2406
2670
  if (!this._running || !controlNode) {
2407
2671
  break;
2408
2672
  }
2673
+ this.refreshDialHint(sibling.peerId, addrs, hints?.get(sibling.peerId)?.addrs);
2409
2674
  // `mergePeerAddrs` owns the whole best-effort contract — including parsing
2410
2675
  // a malformed `CadrePeer.PeerId` — so one bad row cannot abort the pass.
2411
2676
  counts[await mergePeerAddrs(controlNode, sibling.peerId, addrs)]++;
@@ -2413,6 +2678,40 @@ export class CadreNode {
2413
2678
  log('reconcileControlCohort: address book warmed (siblings=%d, merged=%d, restamped=%d, skipped=%d, failed=%d)', resolved.size, counts.merged, counts.restamped, counts.skipped, counts.failed);
2414
2679
  return resolved;
2415
2680
  }
2681
+ /**
2682
+ * Replace a sibling's retained out-of-band dial target (see
2683
+ * {@link bootstrapPeerStore}) with the addresses its signed record just resolved
2684
+ * to, when the two differ — so an address change this node saw while the record
2685
+ * was fresh (a new port, a new LAN address) is what it dials after a relaunch
2686
+ * that outlives the record's freshness window.
2687
+ *
2688
+ * Only a sibling that already HAS an entry (`retained`) is refreshed: the store
2689
+ * holds peers learned out of band and must not grow into a copy of every
2690
+ * sibling's addresses. An empty resolution leaves the entry alone — a stale or
2691
+ * not-yet-signed record is exactly when the entry is needed. The comparison is
2692
+ * order-insensitive and runs on the retained addresses after binding them to the
2693
+ * peer id, so an entry recorded without `/p2p/` suffixes is not rewritten merely
2694
+ * for lacking them.
2695
+ *
2696
+ * NOTE: replaces rather than merges, so the entry becomes exactly what the
2697
+ * sibling announces. If a node's signed record ever lists fewer addresses this
2698
+ * node can reach than it was added with (say an announce override naming only a
2699
+ * public address, for a node added by its LAN address), a relaunch dials the
2700
+ * worse set; merge the two lists here if that shows up.
2701
+ */
2702
+ refreshDialHint(peerId, resolved, retained) {
2703
+ if (!retained || resolved.length === 0) {
2704
+ return;
2705
+ }
2706
+ // Both lists are de-duplicated by `normalizeDialAddrs`, so equal size plus
2707
+ // containment is set equality.
2708
+ const current = resolved.map((addr) => addr.toString());
2709
+ const previous = new Set(this.bootstrapDialAddrs(peerId, retained).map((addr) => addr.toString()));
2710
+ if (current.length === previous.size && current.every((addr) => previous.has(addr))) {
2711
+ return;
2712
+ }
2713
+ this.retainDialTarget(peerId, current, 'refreshDialHint');
2714
+ }
2416
2715
  /**
2417
2716
  * {@link resolvePeerAddrs} for one sibling, best-effort: a control-DB read
2418
2717
  * failure yields `[]` (and the cold-start fallback then gets its turn) rather
@@ -2428,25 +2727,31 @@ export class CadreNode {
2428
2727
  }
2429
2728
  }
2430
2729
  /**
2431
- * Budget for ONE proactive dial of the reconcile pass — steady-state sibling
2730
+ * Time limits for dialing ONE control-network peer from its candidate
2731
+ * addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
2432
2732
  * ({@link dialControlSibling}) and cold-start bootstrap peer
2433
- * ({@link dialBootstrapPeer}) alike, since both feed a whole address list to
2434
- * one `dial()` and both run in a sequential loop that the next entry waits on.
2733
+ * ({@link dialBootstrapPeer}) dials, and — handed to every
2734
+ * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
2735
+ * `dialInvite`.
2435
2736
  *
2436
- * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why the pass bounds
2437
- * this itself rather than inheriting each address's libp2p attempt timeout.
2737
+ * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why a peer's dial is
2738
+ * bounded as a whole, and {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}
2739
+ * for why each address is bounded as well.
2438
2740
  */
2439
- controlDialTimeoutMs() {
2440
- return this.config.network?.controlCohort?.dialTimeoutMs
2441
- ?? DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS;
2741
+ controlDialBudget() {
2742
+ const cohort = this.config.network?.controlCohort;
2743
+ return {
2744
+ perAddressMs: cohort?.perAddressDialTimeoutMs ?? DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
2745
+ totalMs: cohort?.dialTimeoutMs ?? DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
2746
+ };
2442
2747
  }
2443
2748
  /**
2444
2749
  * Dial one sibling from its already-resolved addresses, best-effort. Returns
2445
- * whether a dial was attempted (false when no address resolves).
2750
+ * whether the dial resolved (false when no address resolves or the dial fails).
2446
2751
  *
2447
2752
  * A per-peer failure (NAT, offline, relay down, connection-gater denial, or
2448
- * the {@link controlDialTimeoutMs} budget expiring) is logged and swallowed so
2449
- * one unreachable sibling never aborts the pass — exactly like
2753
+ * the {@link controlDialBudget} expiring) is logged and swallowed so one
2754
+ * unreachable sibling never aborts the pass — exactly like
2450
2755
  * {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
2451
2756
  * simply retried on the next pass.
2452
2757
  */
@@ -2462,7 +2767,7 @@ export class CadreNode {
2462
2767
  }
2463
2768
  try {
2464
2769
  log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
2465
- await withDeadline(this.controlDialTimeoutMs(), `reconcileControlCohort dial of sibling ${sibling.peerId}`, (signal) => controlNode.dial(addrs, { signal }));
2770
+ await dialPeerAddrs(controlNode, addrs, this.controlDialBudget(), `reconcileControlCohort dial of sibling ${sibling.peerId}`);
2466
2771
  return true;
2467
2772
  }
2468
2773
  catch (error) {
@@ -2471,20 +2776,65 @@ export class CadreNode {
2471
2776
  }
2472
2777
  }
2473
2778
  /**
2474
- * A sibling's control-network dial addresses for the reconcile pass.
2779
+ * A sibling's control-network dial addresses for the reconcile pass, from the
2780
+ * first of three sources that yields any:
2781
+ *
2782
+ * 1. the signed, fresh, trust-gated control addresses `resolved` for it by
2783
+ * {@link warmSiblingAddrBook} — passed in rather than re-resolved, so the
2784
+ * pass makes one record query per sibling;
2785
+ * 2. the libp2p address book ({@link peerStoreAddrs}) — the entries `applySeed`
2786
+ * and identify put there, until they age out;
2787
+ * 3. the sibling's retained out-of-band dial target ({@link retainedDialAddrs}).
2788
+ * For a node that cannot listen, this is the only source for a sibling it
2789
+ * added but has not yet connected to: the added node's row stays unsigned
2790
+ * until it self-publishes, which needs the connection this dial opens. It is
2791
+ * also what a relaunch that outlived the record's freshness window uses.
2792
+ *
2793
+ * Returns `[]` (never throws) when none yields an address — that sibling is
2794
+ * skipped this pass.
2475
2795
  *
2476
- * Primary (steady state): the signed, fresh, trust-gated control addresses
2477
- * `resolved` for it by {@link warmSiblingAddrBook} — passed in rather than
2478
- * re-resolved, so the pass makes one record query per sibling. Fallback (cold
2479
- * start): the libp2p peerStore entries `applySeed` populated, used only while
2480
- * the record is not yet resolvable. Returns `[]` (never throws) when neither
2481
- * yields an address — that sibling is skipped this pass.
2796
+ * The list may name transports this node cannot dial (a lent node reports TCP
2797
+ * and `/ws` addresses to a phone that dials WebSockets only). That needs no
2798
+ * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.1.3)
2799
+ * rejects an address no transport can dial before touching the network, so
2800
+ * such an address costs a log line.
2801
+ *
2802
+ * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
2803
+ * addresses one after another under a single deadline, with no limit per
2804
+ * address, and sorts loopback addresses last — so one or two addresses that
2805
+ * never answer used up the whole deadline before the address that worked was
2806
+ * tried. {@link dialPeerAddrs} dials each address on its own time limit instead.
2482
2807
  */
2483
2808
  async resolveControlDialAddrs(peerId, resolved) {
2484
2809
  if (resolved.length > 0) {
2485
2810
  return resolved;
2486
2811
  }
2487
- return this.peerStoreAddrs(peerId);
2812
+ const booked = await this.peerStoreAddrs(peerId);
2813
+ if (booked.length > 0) {
2814
+ return booked;
2815
+ }
2816
+ return this.retainedDialAddrs(peerId);
2817
+ }
2818
+ /**
2819
+ * A sibling's retained out-of-band dial target (see {@link bootstrapPeerStore}),
2820
+ * bound to its peer id through {@link bootstrapDialAddrs}; `[]` when it has none.
2821
+ *
2822
+ * These addresses are never merged into the libp2p address book: they are
2823
+ * unverified, and the address book takes only verified ones (see
2824
+ * `mergePeerAddrs`). Layers that dial by bare peer id use the connection this
2825
+ * dial opens, and after it drops the next reconcile pass dials again.
2826
+ *
2827
+ * NOTE: an entry is consulted here only for a current sibling, so a peer revoked
2828
+ * by another owner is not dialed from it, even though the entry stays (only this
2829
+ * node's own {@link removePeer} forgets one). The cold-start branch
2830
+ * ({@link dialColdStartBootstrap}) still dials every entry while there are no
2831
+ * siblings at all; if entries for revoked peers ever cause dial churn there,
2832
+ * prune entries whose peer has a retired `CadrePeer` row during the membership
2833
+ * refresh.
2834
+ */
2835
+ retainedDialAddrs(peerId) {
2836
+ const entry = this.bootstrapPeerStore?.all().get(peerId);
2837
+ return entry ? this.bootstrapDialAddrs(peerId, entry.addrs) : [];
2488
2838
  }
2489
2839
  /**
2490
2840
  * Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
@@ -2532,37 +2882,55 @@ export class CadreNode {
2532
2882
  * signature-checked against a trust-anchored signer before this runs, and the
2533
2883
  * flag only *selects a dial target*; a dial grants no authority.
2534
2884
  *
2535
- * Self is excluded. `createSeed` projects EVERY `CadrePeer` row, so an owner
2536
- * that applies a seed minted after it joined finds itself in the owner list;
2537
- * retaining that would make the cold-start pass dial this node forever (the
2538
- * steady-state pass filters self out of its sibling list for the same reason).
2885
+ * Addressless peers and self are skipped by {@link retainDialTarget}.
2539
2886
  */
2540
2887
  recordSeedBootstrapPeers(seed) {
2888
+ for (const peer of seed.peers) {
2889
+ if (peer.isOwner) {
2890
+ this.retainDialTarget(peer.peerId, peer.multiaddrs, 'recordSeedBootstrapPeers');
2891
+ }
2892
+ }
2893
+ }
2894
+ /**
2895
+ * Retain (or replace) one peer's out-of-band dial target in
2896
+ * {@link bootstrapPeerStore}. Shared by every writer: seed intake, {@link addDrone}
2897
+ * and {@link refreshDialHint}.
2898
+ *
2899
+ * Fire-and-log: the entry is visible synchronously by the store's contract, and
2900
+ * the promise tracks durability only. A persist failure costs restart survival,
2901
+ * never this session's dial set — the same trade
2902
+ * `SeedBootstrapService.anchorAcceptedSigner` makes — so it is logged rather than
2903
+ * failing a seed or an add that has already been accepted.
2904
+ *
2905
+ * Two peers are never retained. One with no address: there is nothing to dial.
2906
+ * And self: `createSeed` projects EVERY `CadrePeer` row, so an owner that applies
2907
+ * a seed minted after it joined finds itself in the owner list, and `addDrone`
2908
+ * could be handed this node's own id. Retaining self would make the cold-start
2909
+ * pass dial this node forever (the steady-state pass filters self out of its
2910
+ * sibling list for the same reason).
2911
+ */
2912
+ retainDialTarget(peerId, addrs, caller) {
2541
2913
  const store = this.bootstrapPeerStore;
2542
2914
  if (!store) {
2543
2915
  // Unreachable in production: start() builds the store before any network
2544
- // bring-up, and both intake paths need a started node (an uninitialized
2545
- // SeedBootstrapService rejects the seed, so noteAppliedSeed returns early).
2546
- log('recordSeedBootstrapPeers: no bootstrap-peer store yet; retaining nothing');
2916
+ // bring-up, and every caller needs a started node (an uninitialized
2917
+ // SeedBootstrapService rejects a seed or an add, and the reconcile pass
2918
+ // needs a running one).
2919
+ log('%s: no bootstrap-peer store yet; not retaining %s', caller, peerId);
2547
2920
  return;
2548
2921
  }
2549
- const selfPeerId = this.controlNode?.peerId.toString();
2550
- for (const peer of seed.peers) {
2551
- if (!peer.isOwner || peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
2552
- continue;
2553
- }
2554
- // Sync-visible by contract; the promise tracks durability only. A persist
2555
- // failure costs restart survival, never this session's retry set — the same
2556
- // trade `SeedBootstrapService.anchorAcceptedSigner` makes — so log and carry
2557
- // on rather than failing the seed that was already accepted.
2558
- void store.record(peer.peerId, peer.multiaddrs).catch((error) => {
2559
- log('recordSeedBootstrapPeers: persisting bootstrap peer %s failed (retained in memory): %o', peer.peerId, error);
2560
- });
2922
+ if (addrs.length === 0 || peerId === this.controlNode?.peerId.toString()) {
2923
+ return;
2561
2924
  }
2925
+ void store.record(peerId, addrs).catch((error) => {
2926
+ log('%s: persisting dial target %s failed (retained in memory): %o', caller, peerId, error);
2927
+ });
2562
2928
  }
2563
2929
  /**
2564
- * Cold-start branch of the reconcile pass: re-dial the seed's owner peers
2565
- * while the control database still has no siblings to dial.
2930
+ * Cold-start branch of the reconcile pass: re-dial every retained out-of-band
2931
+ * dial target (see {@link bootstrapPeerStore}) — in practice the owner peers of
2932
+ * the seeds this node applied — while the control database still has no
2933
+ * siblings to dial.
2566
2934
  *
2567
2935
  * `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
2568
2936
  * When that single dial fails — owner momentarily down, relay reservation not
@@ -2589,14 +2957,14 @@ export class CadreNode {
2589
2957
  // cannot mutate what we are iterating.
2590
2958
  const targets = this.bootstrapPeerStore?.all();
2591
2959
  if (!controlNode || !targets || targets.size === 0) {
2592
- return;
2960
+ return [];
2593
2961
  }
2594
2962
  // Same skip rule as step 3 of the steady-state pass: no churn on live links.
2595
2963
  const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
2596
- let dialed = 0;
2964
+ const dialed = [];
2597
2965
  for (const [peerId, entry] of targets) {
2598
2966
  if (!this._running || !this.controlNode) {
2599
- return;
2967
+ return dialed;
2600
2968
  }
2601
2969
  // NOTE: a connection this node still holds in `status: 'open'` after the remote
2602
2970
  // has already aborted it counts as connected here, so the cold-start retry stays
@@ -2609,19 +2977,21 @@ export class CadreNode {
2609
2977
  continue;
2610
2978
  }
2611
2979
  if (await this.dialBootstrapPeer(peerId, entry.addrs)) {
2612
- dialed++;
2980
+ dialed.push(peerId);
2613
2981
  }
2614
2982
  }
2615
- log('reconcileControlCohort: cold-start pass complete (bootstrap=%d, connected=%d, dialed=%d)', targets.size, connected.size, dialed);
2983
+ log('reconcileControlCohort: cold-start pass complete (bootstrap=%d, connected=%d, dialed=%d)', targets.size, connected.size, dialed.length);
2984
+ return dialed;
2616
2985
  }
2617
2986
  /**
2618
- * Bind a bootstrap peer's seed addresses to its peer id, so the dial
2619
- * authenticates the peer it is aiming at rather than trusting whoever answers.
2987
+ * Bind a retained dial target's addresses (see {@link bootstrapPeerStore}) to
2988
+ * its peer id, so the dial authenticates the peer it is aiming at rather than
2989
+ * trusting whoever answers.
2620
2990
  *
2621
2991
  * The same {@link normalizeDialAddrs} rule the resolved and address-book paths
2622
2992
  * use, on the third and last source of control-dial candidates: an address that
2623
2993
  * already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
2624
- * (a seed that disagrees with itself is not a dial target); one that does not —
2994
+ * (an entry that disagrees with itself is not a dial target); one that does not —
2625
2995
  * a bare listen addr, or a relay hop with the destination missing — gets the id
2626
2996
  * encapsulated; an unencapsulatable one is dropped rather than dialed bare.
2627
2997
  */
@@ -2641,7 +3011,7 @@ export class CadreNode {
2641
3011
  }
2642
3012
  try {
2643
3013
  log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
2644
- await withDeadline(this.controlDialTimeoutMs(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`, (signal) => controlNode.dial(parsed, { signal }));
3014
+ await dialPeerAddrs(controlNode, parsed, this.controlDialBudget(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`);
2645
3015
  return true;
2646
3016
  }
2647
3017
  catch (error) {
@@ -3308,6 +3678,13 @@ export class CadreNode {
3308
3678
  // (register a config + addStrand); the strand-agnostic seam keeps this
3309
3679
  // class free of any app's join policy. A self-configured strand (config
3310
3680
  // already present) keeps auto-starting below, unchanged.
3681
+ //
3682
+ // Recorded BEFORE the emit so a handler that synchronously drains
3683
+ // `getDiscoveredStrands()` sees this strand too. The watcher never offers
3684
+ // the same strand twice (its `knownStrands` retains the id for the life of
3685
+ // the process), so this map — not the event — is what a late subscriber
3686
+ // reads. See the `strand:discovered` doc in types.ts.
3687
+ this.discoveredStrands.set(strand.Id, strand);
3311
3688
  this.emit('strand:discovered', { strandId: strand.Id, strand });
3312
3689
  return;
3313
3690
  }
@@ -3413,8 +3790,10 @@ export class CadreNode {
3413
3790
  }
3414
3791
  // Stop all strand instances
3415
3792
  await this.strandManager.stopAll();
3416
- // Clear sApp configs
3793
+ // Clear sApp configs, recorded launch refusals and the unclaimed-strand backlog
3417
3794
  this.sAppConfigs.clear();
3795
+ this.strandLaunchRefusals.clear();
3796
+ this.discoveredStrands.clear();
3418
3797
  // Drop delegate-admission state: the grants are scoped to the session that
3419
3798
  // recorded them, so a stop()/start() cycle on this object must not keep
3420
3799
  // admitting delegates announced under the previous one, nor carry stale
@@ -3435,6 +3814,7 @@ export class CadreNode {
3435
3814
  if (this.controlDatabase) {
3436
3815
  this.controlDatabase.setMembershipChangeListener(null);
3437
3816
  this.controlDatabase.setGuardedDeleteListener(null);
3817
+ this.controlDatabase.setControlWriteAbandonedListener(null);
3438
3818
  await this.controlDatabase.close();
3439
3819
  this.controlDatabase = null;
3440
3820
  }
@@ -3461,6 +3841,21 @@ export class CadreNode {
3461
3841
  // healthy and make the embedder's retry a no-op ("already running").
3462
3842
  this._running = false;
3463
3843
  }
3844
+ /**
3845
+ * A gated joiner's database was just published (`StartStrandConfig.onWritable`): the
3846
+ * instance is `'active'` now, so re-arm its idle timer as any activity would. Nothing
3847
+ * records activity on a gated joiner, so its idle timer may already have fired — the
3848
+ * instance read `'idle'` with a hibernate timer pending — and without this the flip
3849
+ * back to `'active'` would leave that timer to quiesce a strand the app just started
3850
+ * using. Emits `strand:writable` after the timers are settled.
3851
+ */
3852
+ handleStrandWritable(strandId) {
3853
+ const instance = this.strandManager.getInstance(strandId);
3854
+ if (instance) {
3855
+ this.hibernationManager.recordActivity(instance);
3856
+ }
3857
+ this.emit('strand:writable', { strandId });
3858
+ }
3464
3859
  // Hibernation callbacks
3465
3860
  async handleStrandIdle(strandId) {
3466
3861
  const instance = this.strandManager.getInstance(strandId);
@@ -3510,9 +3905,10 @@ export class CadreNode {
3510
3905
  log('handleStrandWake: strand %s not found', strandId);
3511
3906
  return;
3512
3907
  }
3513
- // Still live (idle wake, or defensive double-wake): no rebuild needed.
3908
+ // Still live (idle wake, or defensive double-wake): no rebuild needed. A joiner
3909
+ // still behind its first-sync gate wakes back to `'syncing'`, not `'active'`.
3514
3910
  if (instance.libp2pNode || instance.database) {
3515
- instance.status = 'active';
3911
+ instance.status = liveStrandStatus(instance);
3516
3912
  instance.lastActivity = new Date();
3517
3913
  log('Strand %s woke (already live)', strandId);
3518
3914
  this.emit('strand:waking', { strandId });
@@ -3630,7 +4026,7 @@ export class CadreNode {
3630
4026
  await this.holdWakeWindow(instance, windowMs);
3631
4027
  const sawActivity = instance.lastActivity !== activityMark;
3632
4028
  if (sawActivity) {
3633
- instance.status = 'active';
4029
+ instance.status = liveStrandStatus(instance);
3634
4030
  this.emit('strand:waking', { strandId });
3635
4031
  log('Wake window: strand %s saw activity during the window; staying active', strandId);
3636
4032
  return true;
@@ -3687,25 +4083,72 @@ export class CadreNode {
3687
4083
  *
3688
4084
  * A rejected call leaves nothing running but DOES leave the sApp config
3689
4085
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
3690
- * automatic relaunch need it. So once the strand's row is visible on the control
3691
- * network (via {@link publishStrand} or another member), a failed launch keeps
3692
- * being re-attempted in the background — each failure re-emitting `strand:error`
3693
- * — until it succeeds. {@link detachStrand} (reached via {@link stopStrand}) is
3694
- * what abandons a strand for good.
4086
+ * automatic relaunch need it. That background relaunch covers only a strand the
4087
+ * WATCHER launched: it forgets a row whose `onStrandAdded` threw and re-attempts it
4088
+ * on a later poll (each failure re-emitting `strand:error`) until it succeeds. A
4089
+ * strand claimed here after it was DISCOVERED gets no such retry — the watcher
4090
+ * already recorded it in its `knownStrands` when it offered it as
4091
+ * `strand:discovered`, and no later poll re-offers a strand it knows. This call has
4092
+ * also already dropped it from {@link getDiscoveredStrands}, so a failed claim
4093
+ * leaves the strand dormant until the caller retries or the node restarts (see
4094
+ * `backlog/bug-discovered-strand-lost-when-claim-fails`).
4095
+ * {@link detachStrand} (reached via {@link stopStrand}) is what abandons a strand
4096
+ * for good.
3695
4097
  */
3696
4098
  async addStrand(config) {
3697
4099
  if (!this._running) {
3698
4100
  throw new Error('CadreNode not running');
3699
4101
  }
3700
- const { strandRow, sAppConfig, founder } = config;
3701
- // Store sApp config for this strand
4102
+ const { strandRow, sAppConfig, founder, partyMemberPrivateKey } = config;
4103
+ if (partyMemberPrivateKey !== undefined && strandRow.Type !== 'c') {
4104
+ // Only a closed strand seats a Member/Manager, so an open launch would drop this
4105
+ // key silently. Refuse instead: the caller has confused the party identity key
4106
+ // with something an open strand uses, and a silent drop hides that.
4107
+ throw new Error(`addStrand(${strandRow.Id}): partyMemberPrivateKey belongs to a CLOSED strand ` +
4108
+ `(Type 'c'), but this row is Type '${strandRow.Type}' — an open strand seats no ` +
4109
+ 'Member/Manager, so the key would be ignored.');
4110
+ }
4111
+ // Store sApp config for this strand. The strand is claimed now, so it leaves the
4112
+ // unclaimed backlog — a later `getDiscoveredStrands()` drain must not re-offer it.
3702
4113
  this.sAppConfigs.set(strandRow.Id, sAppConfig);
4114
+ this.discoveredStrands.delete(strandRow.Id);
3703
4115
  log('Registered sAppConfig for strand %s (sApp: %s, founder: %s)', strandRow.Id, sAppConfig.id, founder ?? 'derived');
3704
4116
  // An unset `founder` is DERIVED from the row inside launchStrand (this node founds
3705
4117
  // iff the row's FounderOwnerKey is its own owner key); an explicit flag wins — the
3706
4118
  // formation/responder flows pass one deliberately, since their consent-seated rows
3707
- // carry a null column. See StrandConfig.founder.
3708
- return await this.launchStrand(strandRow, sAppConfig, founder);
4119
+ // carry a null column. See StrandConfig.founder. Same rule for the explicit
4120
+ // partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
4121
+ const instance = await this.launchStrand(strandRow, sAppConfig, founder, partyMemberPrivateKey);
4122
+ // The first-sync write gate: a JOINING machine's database is withheld until it has
4123
+ // received the strand's Header from another member, because a write before that
4124
+ // forks every table it touches (see strand-first-sync-gate.ts). Waiting here is what
4125
+ // lets an app write straight after `addStrand` resolves — the reference chat apps
4126
+ // do — so the default is to wait, bounded by `strandFirstSync.timeoutMs`. A timeout
4127
+ // rejects with the retryable `StrandAwaitingFirstSyncError` and leaves the launch
4128
+ // up: this same call, made again once a member is reachable, completes the attach
4129
+ // (the tracked-instance path above returns the still-syncing instance, and this
4130
+ // wait picks it up). Founders and machines that already hold the Header resolve at
4131
+ // once. Only a LIVE gated instance is waited on — a hibernating one is returned as
4132
+ // it always was (no database either; a wake publishes it).
4133
+ if (config.awaitFirstSync !== false && this.strandManager.isAwaitingFirstSync(strandRow.Id)) {
4134
+ await this.strandManager.whenWritable(strandRow.Id, { timeoutMs: this.config.strandFirstSync?.timeoutMs });
4135
+ }
4136
+ return instance;
4137
+ }
4138
+ /**
4139
+ * Resolve once a launched strand is writable — its database published to the app and
4140
+ * its status `'active'` — or reject with the retryable `StrandAwaitingFirstSyncError`
4141
+ * after `timeoutMs` (default `strandFirstSync.timeoutMs`, else
4142
+ * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`). The promise form of the `strand:writable`
4143
+ * event, for a caller that attached without waiting ({@link StrandConfig.awaitFirstSync}
4144
+ * `false`) or that holds a strand the watcher launched. Resolves immediately for a
4145
+ * strand that is already writable; rejects immediately for one this node does not run.
4146
+ * A hibernating strand is not woken — wake it first if that is what is wanted.
4147
+ */
4148
+ async whenStrandWritable(strandId, options) {
4149
+ return await this.strandManager.whenWritable(strandId, {
4150
+ timeoutMs: options?.timeoutMs ?? this.config.strandFirstSync?.timeoutMs
4151
+ });
3709
4152
  }
3710
4153
  /**
3711
4154
  * Publish a strand row to the shared control database under this node's own
@@ -3751,10 +4194,16 @@ export class CadreNode {
3751
4194
  * Failing loudly here is intentional: a silently-unpublished strand would run
3752
4195
  * as a local-only island that no peer could ever discover or join.
3753
4196
  *
4197
+ * A CLOSED strand's publish also seats this party's own membership identity key —
4198
+ * the `StrandPartyKey` row {@link ensureStrandPartyKey} documents — which is what the
4199
+ * founder bootstrap derives `Member.Key`/`Manager.MemberKey` from. The
4200
+ * `memberPrivateKey` ARGUMENT stays the strand-wide read secret formation hands to
4201
+ * joiners; it derives nobody's identity.
4202
+ *
3754
4203
  * @param strandId - Unique strand identifier (typically the same id passed to
3755
4204
  * {@link addStrand}).
3756
4205
  * @param type - `'o'` for open (default) or `'c'` for closed.
3757
- * @param memberPrivateKey - Optional membership key for a closed strand.
4206
+ * @param memberPrivateKey - Optional shared membership (read) key for a closed strand.
3758
4207
  * @returns The live `Strand` row — the one just inserted, or the matching one already
3759
4208
  * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
3760
4209
  * on a repeat it is the stored key, not the argument.
@@ -3792,6 +4241,13 @@ export class CadreNode {
3792
4241
  // so a re-seat is not mistaken for a repeat.
3793
4242
  const matched = requireMatchingStrandRow(existing, desired, 'is already published');
3794
4243
  log('publishStrand(%s): already published (type %s) with identical content — no-op', trimmed, type);
4244
+ // A closed strand THIS machine published must hold a party key (its own membership
4245
+ // identity — see ensureStrandPartyKey). Insert-if-absent, so the ordinary repeat
4246
+ // is a read-only no-op and a publish interrupted between the row insert and the
4247
+ // mint heals here. A row another machine published is that machine's to mint for.
4248
+ if (type === 'c' && this.isSelfFoundedRow(matched)) {
4249
+ await this.ensureStrandPartyKey(trimmed);
4250
+ }
3795
4251
  return matched;
3796
4252
  }
3797
4253
  try {
@@ -3814,6 +4270,14 @@ export class CadreNode {
3814
4270
  return requireMatchingStrandRow(landed, desired, 'landed concurrently from another founder');
3815
4271
  }
3816
4272
  log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signingKey.publicKeyB64);
4273
+ // A closed strand's publish also mints this party's own membership identity key
4274
+ // (StrandPartyKey) — the key the founder bootstrap derives Member/Manager from,
4275
+ // deliberately distinct from the shared memberPrivateKey argument above. A failure
4276
+ // here surfaces (the strand row has already committed, so a retried publish takes
4277
+ // the idempotent branch and heals the mint; so does the next founder launch).
4278
+ if (type === 'c') {
4279
+ await this.ensureStrandPartyKey(trimmed);
4280
+ }
3817
4281
  return desired;
3818
4282
  }
3819
4283
  /**
@@ -3856,15 +4320,18 @@ export class CadreNode {
3856
4320
  * a different `Type`, or either half rejects.
3857
4321
  */
3858
4322
  async foundStrand(config) {
3859
- const { strandId, type = 'o', memberPrivateKey, sAppConfig } = config;
4323
+ const { strandId, type = 'o', memberPrivateKey, sAppConfig, awaitFirstSync } = config;
3860
4324
  if (!this._running || !this.controlDatabase) {
3861
4325
  throw new Error(`CadreNode must be started before attempting to found strand ${strandId}`);
3862
4326
  }
4327
+ const controlDatabase = this.controlDatabase;
3863
4328
  const trimmed = requireNonBlank(strandId, 'strand id');
3864
- const published = await this.controlDatabase.queryStrand(trimmed);
4329
+ const timed = (step, op) => timedStep('foundStrand', trimmed, step, op);
4330
+ const tTotal = performance.now();
4331
+ const published = await timed('queryStrand', () => controlDatabase.queryStrand(trimmed));
3865
4332
  const strandRow = published
3866
4333
  ? this.adoptPublishedStrand(published, type, memberPrivateKey)
3867
- : await this.publishStrand(trimmed, type, memberPrivateKey);
4334
+ : await timed('publishStrand', () => this.publishStrand(trimmed, type, memberPrivateKey));
3868
4335
  // Derived, not hardcoded `true`: adopting a row another machine published means that
3869
4336
  // machine runs the bootstrap — this one must attach or it would write a second Header.
3870
4337
  const founded = this.isSelfFoundedRow(strandRow);
@@ -3873,7 +4340,8 @@ export class CadreNode {
3873
4340
  '(FounderOwnerKey is not this node\'s owner key) — attaching as a joiner; ' +
3874
4341
  'the founder bootstrap runs on the publishing machine', trimmed);
3875
4342
  }
3876
- const instance = await this.addStrand({ strandRow, sAppConfig, founder: founded });
4343
+ const instance = await timed('addStrand', () => this.addStrand({ strandRow, sAppConfig, founder: founded, awaitFirstSync }));
4344
+ timing(`[foundStrand:${trimmed}] total: ${Math.round(performance.now() - tTotal)}ms`);
3877
4345
  return { instance, strandRow, founded };
3878
4346
  }
3879
4347
  /**
@@ -3911,10 +4379,12 @@ export class CadreNode {
3911
4379
  * is an owner-signed control-plane write like every other, not "destroy the network".
3912
4380
  *
3913
4381
  * Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
3914
- * this party's membership secret for that network, and it is stored nowhere else.
3915
- * With it gone the party can never again admit a member to that closed strand, and a
3916
- * re-published row would carry a DIFFERENT key that does not match the membership
3917
- * already written into the strand's RBAC layer. The strand id itself is NOT
4382
+ * this party's read secret for that network, and the strand's `StrandPartyKey` row —
4383
+ * this party's own membership identity, removed (and tombstoned) in the SAME
4384
+ * transaction — is stored nowhere else either. With both gone the party can never
4385
+ * again sign as its seated member/manager there, and a re-published row mints a fresh
4386
+ * identity that does not match the membership already written into the strand's RBAC
4387
+ * layer. The strand id itself is NOT
3918
4388
  * blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
3919
4389
  * stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
3920
4390
  * `Revocation` tombstone names the id, which the consent branch of
@@ -3965,6 +4435,26 @@ export class CadreNode {
3965
4435
  'row deletion itself cannot be replayed, so other nodes may keep running the strand ' +
3966
4436
  'until the collection converges.', trimmed);
3967
4437
  }
4438
+ // A refused pre-split launch left no tracked instance, so the stop below never
4439
+ // reaches detachStrand for it — clear the refusal here so a re-founded id starts clean.
4440
+ this.strandLaunchRefusals.delete(trimmed);
4441
+ // Clear THIS machine's own MemberPeer binding while the strand runtime — and the
4442
+ // retained party key — is still live: the delete above already destroyed the
4443
+ // party's StrandPartyKey row, so once this process forgets the key nothing can
4444
+ // ever sign the removal again. Runs AFTER the control delete so a rejected
4445
+ // unpublish (unauthorized signer) never strips a binding the party still needs;
4446
+ // best-effort by contract (never throws) — a hibernating or unreachable strand
4447
+ // keeps the stale binding, which grants nothing today. Sibling machines observing
4448
+ // this removal via their watchers do NOT clear their own bindings (see the NOTE on
4449
+ // StrandInstanceManager.clearOwnMemberPeerBinding).
4450
+ //
4451
+ // NOTE: this makes unpublishStrand await a STRAND-NETWORK write it never used to —
4452
+ // however long that write takes to reject or commit is now added to unpublish's
4453
+ // wall-clock. Fine while strand writes fail fast on an unreachable quorum; if
4454
+ // unpublish ever starts hanging on an isolated strand, give this call its own
4455
+ // deadline rather than dropping it (a binding cleared late is still worth more than
4456
+ // one never cleared).
4457
+ await this.strandManager.clearOwnMemberPeerBinding(trimmed);
3968
4458
  // Converge locally now rather than waiting up to a poll interval. The watcher fires
3969
4459
  // onStrandRemoved for a strand it tracked; the explicit stop below covers a node whose
3970
4460
  // strandFilter never admitted this strand (the watcher never knew it, so it will never
@@ -3976,6 +4466,101 @@ export class CadreNode {
3976
4466
  }
3977
4467
  log('Unpublished strand %s from control DB under owner %s', trimmed, signingKey.publicKeyB64);
3978
4468
  }
4469
+ /**
4470
+ * Seat this party's own strand membership identity key — the `StrandPartyKey` row —
4471
+ * for `strandId`, minting a fresh one when none is given, and adopting the stored one
4472
+ * when a row already exists. Insert-if-absent and stable thereafter: the founding
4473
+ * `Member.Key` must not change across restarts, or the bootstrap's insert-if-absent
4474
+ * guards stop matching.
4475
+ *
4476
+ * This is the identity half of the closed-strand key split: the row's `PrivateKey` —
4477
+ * NOT the strand row's shared `MemberPrivateKey`, which formation hands to every
4478
+ * joining party — is what the founder bootstrap derives `Member.Key`/`Manager.MemberKey`
4479
+ * from. It replicates to every machine this party owns (same plaintext-at-rest stance
4480
+ * as `MemberPrivateKey`; docs/strands.md → "Closed-Strand Member Key Handling") and is
4481
+ * never put on the formation wire.
4482
+ *
4483
+ * Called internally by {@link publishStrand} (closed strands mint at publish) and by a
4484
+ * founder launch that finds no row (a publish interrupted before its mint); public so a
4485
+ * test harness — or a joiner flow that persists a formation-issued identity — can seat
4486
+ * a specific key deliberately.
4487
+ *
4488
+ * @param strandId - The strand the key is this party's identity for.
4489
+ * @param partyMemberPrivateKey - Optional specific key (base64 protobuf, as
4490
+ * `generateStrandMemberKey` mints). When a row already holds a DIFFERENT key this
4491
+ * throws — a party has one identity per strand; rotation is a deliberate
4492
+ * remove-then-insert, not a silent swap.
4493
+ * @returns The live party key: the one just seated, or the stored one.
4494
+ */
4495
+ async ensureStrandPartyKey(strandId, partyMemberPrivateKey) {
4496
+ const trimmed = requireNonBlank(strandId, 'strand id');
4497
+ const signingKey = this.requireOwnerSigningKey(`seat a party key for strand ${trimmed}`);
4498
+ const existing = await this.controlDatabase.queryStrandPartyKey(trimmed);
4499
+ if (existing !== null) {
4500
+ if (partyMemberPrivateKey !== undefined && partyMemberPrivateKey !== existing) {
4501
+ // Neither key is included: the party key is this party's membership identity secret.
4502
+ throw new Error(`Strand ${trimmed} already has a party key and the supplied one differs. The stored ` +
4503
+ 'key is the identity the strand membership was seated under; to rotate it, remove ' +
4504
+ 'the row deliberately (deleteStrandPartyKey) rather than overwriting it.');
4505
+ }
4506
+ return existing;
4507
+ }
4508
+ const key = partyMemberPrivateKey ?? await generateStrandMemberKey();
4509
+ try {
4510
+ await this.controlDatabase.insertStrandPartyKey(trimmed, key, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
4511
+ }
4512
+ catch (error) {
4513
+ // The read and the insert are not atomic; a concurrent seat of the same strand's
4514
+ // key can land in between. Adopt the landed row when it satisfies this request —
4515
+ // the same resolution publishStrand applies to its own read-then-insert window —
4516
+ // and rethrow every other rejection (unauthorized signer, retired stamp).
4517
+ const landed = await this.controlDatabase.queryStrandPartyKey(trimmed);
4518
+ if (landed === null || (partyMemberPrivateKey !== undefined && partyMemberPrivateKey !== landed)) {
4519
+ throw error;
4520
+ }
4521
+ log('ensureStrandPartyKey(%s): lost a concurrent seat race; adopting the landed key', trimmed);
4522
+ return landed;
4523
+ }
4524
+ log('Seated strand party key for %s under owner %s', trimmed, signingKey.publicKeyB64);
4525
+ return key;
4526
+ }
4527
+ /**
4528
+ * Resolve the party membership key a closed strand's launch threads into the founder
4529
+ * bootstrap: the explicit attach-time key when given, else the party's persisted
4530
+ * `StrandPartyKey` row, else — on the one machine whose owner key the row names as
4531
+ * founder — a freshly minted-and-persisted key, which seats the identity for a publish
4532
+ * that was interrupted before its mint. Everyone else resolves undefined: non-founding
4533
+ * machines never mint (no mint race between a party's machines), and only a founder
4534
+ * bootstrap needs the key at all.
4535
+ *
4536
+ * Minting does NOT repair a strand founded before the key split: its founding
4537
+ * membership was seated under the shared `MemberPrivateKey`, and nothing re-seats it —
4538
+ * the founder bootstrap refuses such a strand (`PreSplitStrandIdentityError`) instead.
4539
+ *
4540
+ * NOTE: "founding machine" here is the ROW's provenance, not the launch's resolved
4541
+ * `founder` flag — so an explicit `founder: false` over a row this machine published
4542
+ * still mints, an owner-signed write a caller that said "I am not founding" did not ask
4543
+ * for. Harmless (the party needs that identity for the strand either way, and the mint
4544
+ * is insert-if-absent) and unreachable today: the one explicit `founder: false` caller
4545
+ * passes a row with a null `FounderOwnerKey`. If a caller ever pairs `founder: false`
4546
+ * with its own published row, gate the mint on the resolved flag too.
4547
+ */
4548
+ async resolveStrandPartyKey(strand, explicitKey) {
4549
+ if (explicitKey !== undefined) {
4550
+ return explicitKey;
4551
+ }
4552
+ if (!this.controlDatabase) {
4553
+ return undefined;
4554
+ }
4555
+ const stored = await this.controlDatabase.queryStrandPartyKey(strand.Id);
4556
+ if (stored !== null) {
4557
+ return stored;
4558
+ }
4559
+ if (!this.isSelfFoundedRow(strand)) {
4560
+ return undefined;
4561
+ }
4562
+ return await this.ensureStrandPartyKey(strand.Id);
4563
+ }
3979
4564
  /**
3980
4565
  * Publish an owner-signed `FormationInvite` (open-invitation token) to the
3981
4566
  * shared control database, so a later {@link formStrand} redemption can be
@@ -4120,24 +4705,61 @@ export class CadreNode {
4120
4705
  * now the tracked instance is founded in place
4121
4706
  * ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
4122
4707
  * so the bootstrap actually runs before this resolves.
4708
+ *
4709
+ * A founder launch refused as pre-split (`PreSplitStrandIdentityError`) is recorded in
4710
+ * {@link strandLaunchRefusals} for the formation arm, then rethrown; a founder launch
4711
+ * that succeeds clears the record (it ran the bootstrap's pre-split check and passed).
4123
4712
  */
4124
- async launchStrand(strand, sAppConfig, founder) {
4713
+ async launchStrand(strand, sAppConfig, founder, explicitPartyKey) {
4125
4714
  const resolvedFounder = founder ?? this.isSelfFoundedRow(strand);
4715
+ try {
4716
+ const instance = await this.startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey);
4717
+ if (resolvedFounder) {
4718
+ this.strandLaunchRefusals.delete(strand.Id);
4719
+ }
4720
+ return instance;
4721
+ }
4722
+ catch (error) {
4723
+ if (error instanceof PreSplitStrandIdentityError) {
4724
+ this.strandLaunchRefusals.set(strand.Id, error);
4725
+ }
4726
+ throw error;
4727
+ }
4728
+ }
4729
+ /**
4730
+ * The launch itself, for {@link launchStrand} (which documents the behaviour): found a
4731
+ * tracked instance in place, or start a fresh one.
4732
+ */
4733
+ async startOrFoundStrand(strand, sAppConfig, resolvedFounder, explicitPartyKey) {
4734
+ const timed = (step, op) => timedStep('startOrFoundStrand', strand.Id, step, op);
4126
4735
  const existing = this.strandManager.getInstance(strand.Id);
4127
4736
  if (existing) {
4128
4737
  if (resolvedFounder) {
4129
- const outcome = await this.strandManager.foundExistingStrand(strand.Id);
4738
+ // The resolver runs only when the retained config lacks a party key for a
4739
+ // closed strand (see foundExistingStrand), so the common watcher re-entry
4740
+ // ('already-founder') still costs no control read.
4741
+ const outcome = await timed('foundExistingStrand', () => this.strandManager.foundExistingStrand(strand.Id, () => this.resolveStrandPartyKey(strand, explicitPartyKey)));
4130
4742
  if (outcome === 'needs-resume') {
4131
- // Quiesced instance: the retained config now founds, but founding promises
4132
- // the bootstrap has RUN by the time the caller resolves — wake through the
4133
- // hibernation manager (coalesced with any in-flight wake, timer-aware) so
4134
- // the rebuild executes it now rather than at some eventual wake.
4135
- await this.wakeStrand(strand.Id);
4136
- // The wake's rebuild founds — UNLESS a wake was already in flight when the
4137
- // config flipped, in which case it had already read the pre-flip config and
4138
- // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
4139
- // (insert-if-absent) bootstrap so founding never resolves headerless.
4140
- await this.strandManager.ensureFounderBootstrap(strand.Id);
4743
+ try {
4744
+ // Quiesced instance: the retained config now founds, but founding promises
4745
+ // the bootstrap has RUN by the time the caller resolves — wake through the
4746
+ // hibernation manager (coalesced with any in-flight wake, timer-aware) so
4747
+ // the rebuild executes it now rather than at some eventual wake.
4748
+ await timed('wakeStrand', () => this.wakeStrand(strand.Id));
4749
+ // The wake's rebuild founds — UNLESS a wake was already in flight when the
4750
+ // config flipped, in which case it had already read the pre-flip config and
4751
+ // rebuilt as a joiner, and `wakeStrand` merely coalesced onto it. Re-run the
4752
+ // (insert-if-absent) bootstrap so founding never resolves headerless.
4753
+ await timed('ensureFounderBootstrap', () => this.strandManager.ensureFounderBootstrap(strand.Id));
4754
+ }
4755
+ catch (error) {
4756
+ // The founding did not happen (e.g. the rebuild refused a pre-split strand and
4757
+ // rolled back, leaving the instance tracked with no runtime): withdraw the flip
4758
+ // so the next attempt re-runs the founding instead of resolving
4759
+ // 'already-founder' over an instance that never founded.
4760
+ this.strandManager.withdrawFounderRequest(strand.Id);
4761
+ throw error;
4762
+ }
4141
4763
  }
4142
4764
  log('launchStrand: strand %s already tracked — founder request honored (%s)', strand.Id, outcome);
4143
4765
  }
@@ -4146,6 +4768,15 @@ export class CadreNode {
4146
4768
  }
4147
4769
  return existing;
4148
4770
  }
4771
+ // A closed strand's launch carries the party's OWN membership identity key: the
4772
+ // explicit attach-time key, else the control-layer StrandPartyKey row — minted here
4773
+ // when this machine is the row-derived founder and no row exists yet (a publish
4774
+ // interrupted before its mint). A joiner with no persisted key threads undefined;
4775
+ // only a FOUNDER bootstrap needs the key, and that path throws loudly without one
4776
+ // (StrandDatabase).
4777
+ const partyMemberPrivateKey = strand.Type === 'c'
4778
+ ? await timed('resolveStrandPartyKey', () => this.resolveStrandPartyKey(strand, explicitPartyKey))
4779
+ : undefined;
4149
4780
  // Each strand node gets its own transport identity, derived from the cadre
4150
4781
  // identity key + strandId (see strand-transport-key.ts). Sharing the
4151
4782
  // control node's key here gave every node one peerId, which collides at a
@@ -4161,16 +4792,17 @@ export class CadreNode {
4161
4792
  // identity is ever supported, fall back to `undefined` here — libp2p then
4162
4793
  // generates a random per-strand key, which still avoids the collision but
4163
4794
  // gives up peerId stability across restarts.
4164
- const transportKey = this.identityKey
4165
- ? await strandTransportKey(this.identityKey, strand.Id)
4795
+ const identityKey = this.identityKey;
4796
+ const transportKey = identityKey
4797
+ ? await timed('strandTransportKey', () => strandTransportKey(identityKey, strand.Id))
4166
4798
  : undefined;
4167
4799
  // Derived BEFORE seed resolution so the seed pass doubles as the delegate
4168
4800
  // announcement and every grant is recorded before `startStrand` runs
4169
4801
  // `libp2p.start()` (the responder records the grant before replying; the
4170
4802
  // client awaits the replies).
4171
4803
  const delegatePeerId = transportKey ? peerIdFromPrivateKey(transportKey).toString() : undefined;
4172
- const bootstrapNodes = await this.resolveCohortSeed(strand.Id, delegatePeerId);
4173
- const instance = await this.strandManager.startStrand({
4804
+ const bootstrapNodes = await timed('resolveCohortSeed', () => this.resolveCohortSeed(strand.Id, delegatePeerId));
4805
+ const instance = await timed('strandManager.startStrand', () => this.strandManager.startStrand({
4174
4806
  strandRow: strand,
4175
4807
  sAppConfig,
4176
4808
  storage: this.config.storage,
@@ -4190,16 +4822,36 @@ export class CadreNode {
4190
4822
  // are on `StartStrandConfig.servingMachines`.
4191
4823
  backfill: this.config.strandBackfill,
4192
4824
  revocationEnforcement: this.config.strandRevocationEnforcement,
4825
+ membershipReconciliation: this.config.strandMembershipReconciliation,
4193
4826
  onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
4827
+ // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
4828
+ // comes up `'syncing'` announces the moment its database is published.
4829
+ firstSync: this.config.strandFirstSync,
4830
+ onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
4831
+ // Re-announce the delegate to ONE relay before the strand node's reservation
4832
+ // supervisor re-drives it (see announceDelegateToRelay). Retained with the
4833
+ // launch config, so a hibernation wake's rebuilt supervisors get it too.
4834
+ announceDelegateToRelay: (announcedStrandId, relayAddr, announcedDelegatePeerId) => this.announceDelegateToRelay(announcedStrandId, relayAddr, announcedDelegatePeerId),
4835
+ // The staged formation invitation seam for the bring-up membership
4836
+ // reconciler: read lazily per pass (a re-formation replaces the entry) and
4837
+ // cleared once spent, burned, or dead — see pendingMembershipInvites.
4838
+ pendingMembershipInvite: {
4839
+ get: () => this.pendingMembershipInvites.get(strand.Id),
4840
+ clear: () => { this.pendingMembershipInvites.delete(strand.Id); }
4841
+ },
4194
4842
  // The RESOLVED flag, never the raw argument — see the doc comment above.
4195
- founder: resolvedFounder
4196
- });
4843
+ founder: resolvedFounder,
4844
+ // Retained with the launch config, so a hibernation wake rebuilds under the
4845
+ // same identity without re-reading the control DB.
4846
+ partyMemberPrivateKey
4847
+ }));
4197
4848
  // The seed reached the node as `bootstrapNodes`, which only enters the
4198
4849
  // address book via @libp2p/bootstrap discovery. Merge it directly too, so a
4199
4850
  // sibling is dialable by bare peer id from the first moment (see
4200
4851
  // mergeStrandPeerAddrs; refreshStrandPeerAddrs keeps it warm from here on).
4201
- if (instance.libp2pNode) {
4202
- await this.mergeStrandPeerAddrs(instance.libp2pNode, bootstrapNodes, strand.Id);
4852
+ const strandNode = instance.libp2pNode;
4853
+ if (strandNode) {
4854
+ await timed('mergeStrandPeerAddrs', () => this.mergeStrandPeerAddrs(strandNode, bootstrapNodes, strand.Id));
4203
4855
  }
4204
4856
  this.hibernationManager.trackStrand(instance);
4205
4857
  this.emit('strand:started', { strandId: strand.Id });
@@ -4345,10 +4997,10 @@ export class CadreNode {
4345
4997
  * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
4346
4998
  * per-peer failure to `[]` and reports no per-peer success, and threading
4347
4999
  * success out would change its API for little gain. A failed INITIAL announce
4348
- * is fatal-at-start anyway (the relay denies the reservation,
4349
- * `libp2p.start()` throws, and wake/check-in re-resume retries with a fresh
4350
- * announce); a failed REFRESH retries within `DELEGATE_GRANT_TTL_MS / 2`
4351
- * (15 min), still inside the 30 min TTL.
5000
+ * costs the strand supervisor its first attempt only (the relay denies the
5001
+ * reservation; every re-drive re-announces first through
5002
+ * {@link announceDelegateToRelay}); a failed REFRESH retries within
5003
+ * `DELEGATE_GRANT_TTL_MS / 2` (15 min), still inside the 30 min TTL.
4352
5004
  */
4353
5005
  recordDelegateAnnounces(relayPeerIds, strandId, now = Date.now()) {
4354
5006
  for (const relayPeerId of relayPeerIds) {
@@ -4390,6 +5042,49 @@ export class CadreNode {
4390
5042
  }
4391
5043
  await Promise.all([...running].map(([strandId, delegatePeerId]) => this.announceDelegateToDueRelays(strandId, delegatePeerId, relays, now)));
4392
5044
  }
5045
+ /**
5046
+ * Announce ONE strand's delegate peerId to ONE relay, UNTHROTTLED — the
5047
+ * `beforeRedrive` hook of that strand node's per-relay reservation supervisor
5048
+ * (`strand-instance-manager.ts` → `buildStrandRuntime`), run before every
5049
+ * re-drive after the first attempt.
5050
+ *
5051
+ * Why a re-drive must re-announce first: a party control node running the relay
5052
+ * server admits the strand node's derived peerId on an in-memory delegate grant
5053
+ * (`delegate-admission.ts`), and a relay restart drops every grant it held
5054
+ * without telling the announcer. {@link refreshDelegateGrants} would re-announce
5055
+ * on its own at most every `DELEGATE_GRANT_TTL_MS / 2` (15 min) per (relay,
5056
+ * strand) — far slower than the supervisor's backoff — so without this the first
5057
+ * re-drives after a relay restart would be denied at the relay's connection gate.
5058
+ * The throttle map is updated afterwards, so the periodic pass does not announce
5059
+ * again right away.
5060
+ *
5061
+ * Against a dedicated ops relay (no strand-addr RPC) the request fails per-peer
5062
+ * and `collectStrandAddrs` folds it to `[]`: one wasted protocol negotiation per
5063
+ * re-drive attempt, bounded by the supervisor's backoff. Never throws on that
5064
+ * path; a relay addr that names no peer id is logged and skipped (the hook's
5065
+ * caller runs the drive regardless).
5066
+ *
5067
+ * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
5068
+ * timeouts (10 s each: dial by peer id, then by addr) before the 10 s drive even
5069
+ * starts, so one failed re-drive can hold the supervisor `driving` for ~30 s.
5070
+ * Bounded and harmless while the relay is unreachable anyway; if recovery
5071
+ * latency after a relay comes back ever matters, skip the announce when the
5072
+ * control node holds no connection to the relay (the drive's own dial fails
5073
+ * faster) rather than shortening the strand-addr timeout.
5074
+ */
5075
+ async announceDelegateToRelay(strandId, relayAddr, delegatePeerId) {
5076
+ const controlNode = this.controlNode;
5077
+ if (!controlNode) {
5078
+ return;
5079
+ }
5080
+ const relayPeerId = trailingPeerId(multiaddr(relayAddr));
5081
+ if (relayPeerId === null) {
5082
+ log('announceDelegateToRelay: relay addr %s names no peer id; strand %s not announced', relayAddr, strandId);
5083
+ return;
5084
+ }
5085
+ await collectStrandAddrs(controlNode, [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }], strandId, { delegatePeerId });
5086
+ this.recordDelegateAnnounces([relayPeerId], strandId);
5087
+ }
4393
5088
  /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
4394
5089
  async announceDelegateToDueRelays(strandId, delegatePeerId, relays, now) {
4395
5090
  const controlNode = this.controlNode;
@@ -4616,8 +5311,12 @@ export class CadreNode {
4616
5311
  /**
4617
5312
  * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
4618
5313
  * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
4619
- * intact, so on the next node restart (or watcher poll) the strand is rediscovered and
4620
- * surfaces as `strand:discovered` again. Party-wide removal is {@link unpublishStrand}.
5314
+ * intact, so on the next node RESTART the strand is rediscovered and surfaces as
5315
+ * `strand:discovered` again — not on the next watcher poll, which never re-offers a
5316
+ * strand it has already seen (`StrandWatcher.knownStrands` retains the id for the life
5317
+ * of the process). The stop also drops the strand from {@link getDiscoveredStrands},
5318
+ * so a drain cannot undo a deliberate stop. Party-wide removal is
5319
+ * {@link unpublishStrand}.
4621
5320
  */
4622
5321
  async stopStrand(strandId) {
4623
5322
  if (!this._running) {
@@ -4627,20 +5326,28 @@ export class CadreNode {
4627
5326
  }
4628
5327
  /**
4629
5328
  * Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
4630
- * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config, stop
4631
- * the instance, emit `strand:stopped`. Touches no control-plane row — which side of the
4632
- * removal the node is on is the caller's concern, not this method's.
5329
+ * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config and any
5330
+ * recorded launch refusal, stop the instance, emit `strand:stopped`. Touches no
5331
+ * control-plane row — which side of the removal the node is on is the caller's concern,
5332
+ * not this method's.
4633
5333
  *
4634
5334
  * The stop + emit are skipped when the strand manager holds no instance for `strandId` —
4635
5335
  * e.g. a party owner that published a strand's row but never ran it locally, or an
4636
5336
  * explicit {@link stopStrand} for an id this node never started. `hibernationManager`
4637
- * untrack and the `sAppConfigs` delete stay unconditional (both are no-ops when there is
4638
- * nothing to remove), so a launch that failed before an instance was ever tracked still
4639
- * gets its stray sApp config cleared.
5337
+ * untrack and the `sAppConfigs` / {@link strandLaunchRefusals} / {@link discoveredStrands}
5338
+ * deletes stay unconditional (all no-ops when there is nothing to remove), so a launch
5339
+ * that failed before an instance was ever tracked still gets its stray entries cleared.
5340
+ *
5341
+ * Dropping the {@link discoveredStrands} entry here covers both callers, and both
5342
+ * readings are the intended one: `handleStrandRemoved` arrives because the control row
5343
+ * is gone, and an explicit {@link stopStrand} is a deliberate abandonment — neither
5344
+ * strand may be re-offered to a later `getDiscoveredStrands()` drain.
4640
5345
  */
4641
5346
  async detachStrand(strandId) {
4642
5347
  this.hibernationManager.untrackStrand(strandId);
4643
5348
  this.sAppConfigs.delete(strandId);
5349
+ this.strandLaunchRefusals.delete(strandId);
5350
+ this.discoveredStrands.delete(strandId);
4644
5351
  if (!this.strandManager.hasStrand(strandId)) {
4645
5352
  log('detachStrand: strand %s not tracked locally — no-op (no stop, no strand:stopped)', strandId);
4646
5353
  return;
@@ -4856,6 +5563,11 @@ export class CadreNode {
4856
5563
  * relay indistinguishable from a hung start, and much shorter would fail nodes
4857
5564
  * on links that were merely slow. The retries carry on in the background after
4858
5565
  * this resolves, exactly as they do for a {@link reserveRelays} caller.
5566
+ *
5567
+ * `network.requireRelay === false` softens only the outcome below: a first
5568
+ * attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
5569
+ * `start()` carries on with the retry supervisor already running in the
5570
+ * background (the same supervisor {@link reserveRelays} always starts).
4859
5571
  */
4860
5572
  async driveControlRelayReservation() {
4861
5573
  const relayAddrs = this.config.network?.relayAddrs ?? [];
@@ -4866,6 +5578,11 @@ export class CadreNode {
4866
5578
  const state = await this.reserveRelays([...relayAddrs]);
4867
5579
  timing('[start] reserveRelays: %dms', Math.round(performance.now() - t0));
4868
5580
  if (state.status !== 'reserved') {
5581
+ if (this.config.network?.requireRelay === false) {
5582
+ log('Relay reservation did not land on the first attempt (status: %s); ' +
5583
+ 'continuing without it because network.requireRelay is false: %o', state.status, state);
5584
+ return;
5585
+ }
4869
5586
  throw new RelayReservationFailedError(relayAddrs, state);
4870
5587
  }
4871
5588
  log('Reserved a relay slot: %o', state.circuitAddrs);
@@ -5001,6 +5718,7 @@ export class CadreNode {
5001
5718
  partyId: this.config.controlNetwork.partyId,
5002
5719
  ownerPrivateKey,
5003
5720
  inviteAddressResolver: () => this.resolveInviteAddresses(),
5721
+ dialBudget: this.controlDialBudget(),
5004
5722
  trustPolicy: this.config.seedTrustPolicy,
5005
5723
  // Seed trust anchors on the node-local store (seeded just above with this
5006
5724
  // node's own genesis key), never on the replicated OwnerKey table.
@@ -5240,6 +5958,7 @@ export class CadreNode {
5240
5958
  partyId: this.config.controlNetwork.partyId,
5241
5959
  // No owner key - this node only receives seeds
5242
5960
  inviteAddressResolver: () => this.resolveInviteAddresses(),
5961
+ dialBudget: this.controlDialBudget(),
5243
5962
  trustPolicy: this.config.seedTrustPolicy,
5244
5963
  // A listener-only node accepts a wire-delivered seed solely against this
5245
5964
  // anchor (there is no per-call override on the inbound handler): with no
@@ -5407,6 +6126,10 @@ export class CadreNode {
5407
6126
  await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
5408
6127
  // Queue for re-replication if this committed local-only (no connected cohort).
5409
6128
  this.noteControlWrite(peerId, 'authorize');
6129
+ // NOTE: unlike addDrone, `multiaddrs` is not retained as a dial target: the
6130
+ // flows that vouch through here have the new peer reach this node (it applies a
6131
+ // seed naming the owners, or it dialed in with an invite). If one ever vouches a
6132
+ // peer that cannot reach this node, retain its addresses as addDrone does.
5410
6133
  }
5411
6134
  /**
5412
6135
  * Remove a previously-authorized peer from the cadre.
@@ -5423,6 +6146,13 @@ export class CadreNode {
5423
6146
  await this.seedBootstrapService.removePeer(peerId);
5424
6147
  // Track + loudly flag a delete that committed local-only (security-relevant).
5425
6148
  this.noteControlWrite(peerId, 'remove');
6149
+ // A removed peer is no longer a dial target, however this node learned its
6150
+ // address. Gone from the store synchronously; a persist failure only lets the
6151
+ // entry reappear after a restart, so it is logged rather than failing a
6152
+ // removal that has already committed.
6153
+ void this.bootstrapPeerStore?.forget(peerId).catch((error) => {
6154
+ log('removePeer: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
6155
+ });
5426
6156
  }
5427
6157
  /**
5428
6158
  * Create a seed from the current control network state.
@@ -5459,6 +6189,7 @@ export class CadreNode {
5459
6189
  const tempService = new SeedBootstrapService({
5460
6190
  partyId: seed.partyId,
5461
6191
  trustPolicy: this.config.seedTrustPolicy,
6192
+ dialBudget: this.controlDialBudget(),
5462
6193
  // The anchor is node-scoped, not service-scoped: a throwaway service
5463
6194
  // must consult (and persist an accepted signer into) the SAME store the
5464
6195
  // persistent one would, or a cold-start enrollment via this path would
@@ -5539,6 +6270,20 @@ export class CadreNode {
5539
6270
  /**
5540
6271
  * Add a drone to the cadre (for phone/server adding provider-hosted node).
5541
6272
  * Creates authorization and seed for drone initialization.
6273
+ *
6274
+ * Also retains the drone's handed-over addresses as a durable dial target (see
6275
+ * {@link bootstrapPeerStore}). The drone cannot dial an owner that does not
6276
+ * listen (a phone), and its `CadrePeer` row stays unsigned — so unresolvable —
6277
+ * until it self-publishes over a connection; this node therefore has to open
6278
+ * that connection from the addresses it was handed, on this launch or a later
6279
+ * one.
6280
+ *
6281
+ * Nothing is dialed here: the drone has not received the seed yet. After
6282
+ * delivering it, call {@link reconcileControlCohort} to dial straight away;
6283
+ * otherwise the next timed reconcile pass does. A pass already in flight is
6284
+ * joined rather than restarted, and one that listed siblings before this add
6285
+ * does not dial the drone, so a caller waiting for the connection should allow
6286
+ * for one more timed pass.
5542
6287
  */
5543
6288
  async addDrone(options) {
5544
6289
  if (!this.seedBootstrapService) {
@@ -5546,7 +6291,12 @@ export class CadreNode {
5546
6291
  }
5547
6292
  // The drone's `CadrePeer` insert notifies the membership hub on the way to the
5548
6293
  // seed, so the per-stream gate already admits it here.
5549
- return await this.seedBootstrapService.addDrone(options);
6294
+ const result = await this.seedBootstrapService.addDrone(options);
6295
+ // Same queueing as authorizePeer: a phone adding its first always-on node has
6296
+ // no control connection, so the insert usually committed local-only.
6297
+ this.noteControlWrite(options.dronePeerId, 'authorize');
6298
+ this.retainDialTarget(options.dronePeerId, options.droneMultiaddrs, 'addDrone');
6299
+ return result;
5550
6300
  }
5551
6301
  /**
5552
6302
  * Create an invite for a phone to join the cadre.
@@ -5619,6 +6369,7 @@ export class CadreNode {
5619
6369
  const tempService = new SeedBootstrapService({
5620
6370
  partyId: invite.partyId,
5621
6371
  trustPolicy: this.config.seedTrustPolicy,
6372
+ dialBudget: this.controlDialBudget(),
5622
6373
  });
5623
6374
  if (this.controlNode && this.controlDatabase) {
5624
6375
  tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
@@ -5648,7 +6399,11 @@ export class CadreNode {
5648
6399
  // Overrides any caller-supplied hook, exactly like partyId/cadrePeerAddrs: only
5649
6400
  // this node can say which strand-network addresses it is actually listening on,
5650
6401
  // and a wrong answer here seeds a joiner's mesh with addresses that reach nobody.
5651
- resolveStrandAddrs: (strandId) => this.getStrandMultiaddrs(strandId)
6402
+ resolveStrandAddrs: (strandId) => this.getStrandMultiaddrs(strandId),
6403
+ // Overridden on the same grounds: only this node holds the running strand
6404
+ // instance's database and this party's `StrandPartyKey` identity, and a wrong
6405
+ // issuer here would admit joiners under someone else's authority.
6406
+ issueMembershipInvite: (strandId) => this.issueStrandMembershipInvite(strandId)
5652
6407
  });
5653
6408
  // Register as responder on the control node
5654
6409
  this.strandSolicitationService.registerResponder(this.controlNode);
@@ -5695,8 +6450,143 @@ export class CadreNode {
5695
6450
  }
5696
6451
  const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
5697
6452
  this.recordCrossPartyStrandAddrs(result.strandId, result.strandAddrs);
6453
+ // A closed host strand's approval carries the joiner's own membership invitation —
6454
+ // persist this party's identity key and stage the invitation for bring-up to redeem.
6455
+ // Runs AFTER the addr recording so a persistence failure (which throws — see the
6456
+ // method) still leaves the cross-party seed in place for a manual recovery.
6457
+ if (result.membershipInvite) {
6458
+ await this.adoptFormationMembershipInvite(result.strandId, result.membershipInvite);
6459
+ }
5698
6460
  return result;
5699
6461
  }
6462
+ /**
6463
+ * Adopt an approved closed-strand formation's membership half on the JOINER:
6464
+ *
6465
+ * 1. Mint-or-reuse this party's own strand identity — {@link ensureStrandPartyKey}
6466
+ * with no explicit key returns the stored `StrandPartyKey` row when one exists (a
6467
+ * re-formation by an existing member party: lost addresses, an app reinstall with
6468
+ * an intact control DB) and mints + persists a fresh one otherwise. The invitation
6469
+ * will admit THIS key's public half as the `Strand.Member`.
6470
+ * 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
6471
+ * bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`.
6472
+ *
6473
+ * Throws — failing the whole {@link formStrand} — when the identity cannot be
6474
+ * persisted: a joiner "joined" without a persistable identity could never become a
6475
+ * member, and failing loudly beats a silent half-member. The formation itself has
6476
+ * already succeeded by then and its one-time token is SPENT, so the error says so:
6477
+ * recovery is fixing the underlying cause (no owner signing key / control DB write
6478
+ * rejected) and redeeming a FRESH invitation.
6479
+ */
6480
+ async adoptFormationMembershipInvite(strandId, invite) {
6481
+ try {
6482
+ await this.ensureStrandPartyKey(strandId);
6483
+ }
6484
+ catch (error) {
6485
+ throw new Error(`Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
6486
+ 'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
6487
+ 'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
6488
+ 'invitation (the delivered one dies with this error).', { cause: error });
6489
+ }
6490
+ // NOTE: staging alone is not enough on a node whose reconciler for this strand has
6491
+ // already finished. `StrandMembershipReconciler` latches a terminal stopped state on
6492
+ // its `done` path and is rebuilt only by a strand relaunch, so a RE-formation against
6493
+ // an already-launched strand stages an invitation no loop will ever look at. Harmless
6494
+ // for a party that is still a member (the invitation would only be burned), and the
6495
+ // live defect for one that has been REMOVED — which is exactly when an app re-forms.
6496
+ // Tracked as `backlog/bug-removed-party-cannot-redeem-its-way-back`; pinned by
6497
+ // `strand-party-removal-via-formation-e2e.integration.ts` (test 2).
6498
+ this.pendingMembershipInvites.set(strandId, invite);
6499
+ log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
6500
+ }
6501
+ /**
6502
+ * The pending single-use membership invitation a closed-strand formation carried back
6503
+ * for `strandId`, or `undefined` when none is staged — the seam the strand bring-up
6504
+ * membership reconciler (`strand-membership-reconciler.ts`) reads to redeem the
6505
+ * joiner's `Strand.Member` seat. `undefined` therefore also means "already redeemed,
6506
+ * burned, or found dead": the reconciler clears the entry as soon as it settles the
6507
+ * invitation, so a caller polling this sees it disappear on its own. In-memory only;
6508
+ * see {@link pendingMembershipInvites} for lifetime and re-formation semantics.
6509
+ */
6510
+ getPendingMembershipInvite(strandId) {
6511
+ return this.pendingMembershipInvites.get(strandId);
6512
+ }
6513
+ /**
6514
+ * Responder-side issuer behind the formation manager's `issueMembershipInvite` seam
6515
+ * (see `StrandFormationManagerOptions.issueMembershipInvite` for the contract this
6516
+ * implements): mint a single-use `Strand.Invite` against the LIVE host strand so a
6517
+ * validated joiner can seat its own `Strand.Member` row.
6518
+ *
6519
+ * - Open host strand → `null` (no members, nothing to invite into).
6520
+ * - Closed host strand whose founder launch was refused as pre-split
6521
+ * ({@link strandLaunchRefusals}) → rethrow the recorded `PreSplitStrandIdentityError`;
6522
+ * the manager maps it to the NON-retryable `HOST_STRAND_MUST_BE_RECREATED_REASON`.
6523
+ * - Closed host strand with no `StrandPartyKey` row → throw: this party's identity is
6524
+ * the invite's issuing manager, and without it nothing can sign the issuance. (The
6525
+ * founder's publish/launch paths mint it, so this is a not-yet-converged sibling.)
6526
+ * The manager maps the throw to a clean retryable rejection BEFORE the formation
6527
+ * token is spent.
6528
+ * - Closed host strand with no running local instance/database → throw, same mapping:
6529
+ * a joiner admitted without an invitation would look joined and never become a
6530
+ * member, and a responder not running the strand cannot serve its sync anyway.
6531
+ * - Closed host strand whose LIVE rows carry the pre-split fingerprint
6532
+ * (`assertNotPreSplitStrand`) → throw `PreSplitStrandIdentityError`, same mapping as
6533
+ * the recorded refusal. Covers the responders that never ran a refused founder launch:
6534
+ * a sibling machine of the founding party, or a node restarted since the refusal.
6535
+ *
6536
+ * The recorded refusal is checked first: it is an in-memory read and the only
6537
+ * permanent diagnosis. Identity is checked BEFORE the runtime: it is the cheaper read
6538
+ * and the more actionable diagnosis when both are missing (a missing runtime is
6539
+ * transient, a missing identity is not), and it keeps the branch reachable without
6540
+ * standing a strand runtime up.
6541
+ *
6542
+ * The invitation expires `MEMBERSHIP_INVITE_TTL_MS` from now — see that constant for
6543
+ * the slow-joiner / lost-result tradeoff.
6544
+ *
6545
+ * NOTE: the issuing identity must be a `Strand.Manager` (the schema's `InviteValid`
6546
+ * gate), and only the FOUNDING party's key is seated as one. Today only the founder
6547
+ * party can host a bound formation at all — a joining party never gets the host
6548
+ * strand's control `Strand` row, so `resolveStrand` reports `missing` on it — so this
6549
+ * never bites. If a joined party is ever able to host formations into a strand it
6550
+ * joined (re-invite / multi-hop join), issuance here fails the manager gate and every
6551
+ * such redemption rejects with the retryable-sounding
6552
+ * `MEMBERSHIP_INVITE_UNAVAILABLE_REASON` forever; that flow needs manager delegation,
6553
+ * not a retry.
6554
+ */
6555
+ async issueStrandMembershipInvite(strandId) {
6556
+ if (!this.controlDatabase) {
6557
+ throw new Error(`Cannot issue a membership invitation for strand ${strandId}: control database unavailable`);
6558
+ }
6559
+ const row = await this.controlDatabase.queryStrand(strandId);
6560
+ if (!row) {
6561
+ // The bound resolution saw this row moments ago; a vanished row is a concurrent
6562
+ // unpublish — reject rather than invite into a strand this party just removed.
6563
+ throw new Error(`Cannot issue a membership invitation for strand ${strandId}: its Strand row is gone`);
6564
+ }
6565
+ if (row.Type !== 'c') {
6566
+ return null;
6567
+ }
6568
+ const refusal = this.strandLaunchRefusals.get(strandId);
6569
+ if (refusal) {
6570
+ throw refusal;
6571
+ }
6572
+ const partyKey = await this.controlDatabase.queryStrandPartyKey(strandId);
6573
+ if (partyKey === null) {
6574
+ throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: this party holds ` +
6575
+ 'no StrandPartyKey row for it (identity not yet converged from the machine that published it)');
6576
+ }
6577
+ const db = this.strandManager.getInstance(strandId)?.database?.getDatabase();
6578
+ if (!db) {
6579
+ throw new Error(`Cannot issue a membership invitation for closed strand ${strandId}: its runtime is ` +
6580
+ 'not live on this responder (not launched, hibernating, or quiescing)');
6581
+ }
6582
+ if (row.MemberPrivateKey) {
6583
+ await assertNotPreSplitStrand(db, strandId, strandMemberKeyPair(row.MemberPrivateKey).publicKeyB64);
6584
+ }
6585
+ return await issueInvite(db, {
6586
+ managerKeyPair: strandMemberKeyPair(partyKey),
6587
+ expiration: Date.now() + MEMBERSHIP_INVITE_TTL_MS
6588
+ });
6589
+ }
5700
6590
  /**
5701
6591
  * Remember the responder's strand-network addresses for a strand this node just
5702
6592
  * formed, so the strand's discovery seed has something to work with when the app