@serfab/cadre-core 1.14.0 → 1.16.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 (171) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +64 -29
  3. package/dist/cadre-invite-protocol.js +207 -38
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +375 -79
  9. package/dist/cadre-node.js +758 -186
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-database.d.ts +92 -12
  12. package/dist/control-database.js +323 -142
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +19 -3
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/ed25519-key.d.ts +7 -9
  18. package/dist/ed25519-key.js +20 -10
  19. package/dist/ed25519-key.js.map +1 -1
  20. package/dist/fetch-impl.d.ts +12 -0
  21. package/dist/fetch-impl.js +23 -0
  22. package/dist/fetch-impl.js.map +1 -0
  23. package/dist/file-durable-slot.js +2 -1
  24. package/dist/file-durable-slot.js.map +1 -1
  25. package/dist/file-safe-name.d.ts +25 -0
  26. package/dist/file-safe-name.js +29 -0
  27. package/dist/file-safe-name.js.map +1 -0
  28. package/dist/formation-approval.js +5 -11
  29. package/dist/formation-approval.js.map +1 -1
  30. package/dist/fs-atomic.d.ts +0 -19
  31. package/dist/fs-atomic.js +3 -24
  32. package/dist/fs-atomic.js.map +1 -1
  33. package/dist/index.d.ts +12 -5
  34. package/dist/index.js +31 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/invitation-bootstrap.d.ts +50 -12
  37. package/dist/invitation-bootstrap.js +76 -13
  38. package/dist/invitation-bootstrap.js.map +1 -1
  39. package/dist/joined-strand-store.d.ts +7 -0
  40. package/dist/joined-strand-store.js +9 -0
  41. package/dist/joined-strand-store.js.map +1 -1
  42. package/dist/key-store-file.js +2 -1
  43. package/dist/key-store-file.js.map +1 -1
  44. package/dist/link-budget.d.ts +9 -4
  45. package/dist/link-budget.js +9 -4
  46. package/dist/link-budget.js.map +1 -1
  47. package/dist/machine-removal.d.ts +78 -0
  48. package/dist/machine-removal.js +166 -0
  49. package/dist/machine-removal.js.map +1 -0
  50. package/dist/membership-connection-gater.d.ts +6 -5
  51. package/dist/membership-connection-gater.js +6 -5
  52. package/dist/membership-connection-gater.js.map +1 -1
  53. package/dist/node-claim-payload.d.ts +55 -0
  54. package/dist/node-claim-payload.js +108 -4
  55. package/dist/node-claim-payload.js.map +1 -1
  56. package/dist/node-default-transports.d.ts +27 -0
  57. package/dist/node-default-transports.js +35 -0
  58. package/dist/node-default-transports.js.map +1 -0
  59. package/dist/node-transports.d.ts +30 -0
  60. package/dist/node-transports.js +40 -0
  61. package/dist/node-transports.js.map +1 -0
  62. package/dist/peer-dial.d.ts +124 -27
  63. package/dist/peer-dial.js +254 -30
  64. package/dist/peer-dial.js.map +1 -1
  65. package/dist/peer-join-backfill.js +6 -0
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/peer-record.d.ts +6 -45
  68. package/dist/peer-record.js +8 -50
  69. package/dist/peer-record.js.map +1 -1
  70. package/dist/primary-lan-address.d.ts +8 -0
  71. package/dist/primary-lan-address.js +46 -0
  72. package/dist/primary-lan-address.js.map +1 -0
  73. package/dist/provider/client.d.ts +131 -0
  74. package/dist/provider/client.js +226 -0
  75. package/dist/provider/client.js.map +1 -0
  76. package/dist/provider/descriptor.d.ts +41 -0
  77. package/dist/provider/descriptor.js +103 -0
  78. package/dist/provider/descriptor.js.map +1 -0
  79. package/dist/provider/index.d.ts +9 -0
  80. package/dist/provider/index.js +10 -0
  81. package/dist/provider/index.js.map +1 -0
  82. package/dist/provider/oauth.d.ts +125 -0
  83. package/dist/provider/oauth.js +199 -0
  84. package/dist/provider/oauth.js.map +1 -0
  85. package/dist/provider/rent-node.d.ts +101 -0
  86. package/dist/provider/rent-node.js +220 -0
  87. package/dist/provider/rent-node.js.map +1 -0
  88. package/dist/relay-addrs.d.ts +9 -4
  89. package/dist/relay-addrs.js +9 -4
  90. package/dist/relay-addrs.js.map +1 -1
  91. package/dist/relay-reservation.d.ts +51 -16
  92. package/dist/relay-reservation.js +107 -24
  93. package/dist/relay-reservation.js.map +1 -1
  94. package/dist/seed-bootstrap.d.ts +22 -5
  95. package/dist/seed-bootstrap.js +48 -25
  96. package/dist/seed-bootstrap.js.map +1 -1
  97. package/dist/strand-commit-durability.d.ts +54 -0
  98. package/dist/strand-commit-durability.js +74 -0
  99. package/dist/strand-commit-durability.js.map +1 -0
  100. package/dist/strand-database.d.ts +23 -8
  101. package/dist/strand-database.js +41 -13
  102. package/dist/strand-database.js.map +1 -1
  103. package/dist/strand-first-sync-gate.d.ts +7 -1
  104. package/dist/strand-first-sync-gate.js +7 -1
  105. package/dist/strand-first-sync-gate.js.map +1 -1
  106. package/dist/strand-formation-protocol.js +4 -1
  107. package/dist/strand-formation-protocol.js.map +1 -1
  108. package/dist/strand-fret-addrs.d.ts +19 -1
  109. package/dist/strand-fret-addrs.js +27 -9
  110. package/dist/strand-fret-addrs.js.map +1 -1
  111. package/dist/strand-instance-manager.d.ts +81 -19
  112. package/dist/strand-instance-manager.js +216 -105
  113. package/dist/strand-instance-manager.js.map +1 -1
  114. package/dist/strand-move.d.ts +124 -0
  115. package/dist/strand-move.js +438 -0
  116. package/dist/strand-move.js.map +1 -0
  117. package/dist/strand-network-config.js +7 -6
  118. package/dist/strand-network-config.js.map +1 -1
  119. package/dist/strand-peer-redial.d.ts +113 -0
  120. package/dist/strand-peer-redial.js +236 -0
  121. package/dist/strand-peer-redial.js.map +1 -0
  122. package/dist/strand-wake-protocol.js +10 -7
  123. package/dist/strand-wake-protocol.js.map +1 -1
  124. package/dist/types.d.ts +212 -4
  125. package/dist/types.js +10 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +18 -10
  128. package/src/cadre-invite-protocol.ts +1052 -864
  129. package/src/cadre-join-request.ts +167 -0
  130. package/src/cadre-node.ts +10003 -9381
  131. package/src/control-database.ts +4663 -4451
  132. package/src/control-schema.ts +19 -3
  133. package/src/ed25519-key.ts +20 -10
  134. package/src/fetch-impl.ts +23 -0
  135. package/src/file-durable-slot.ts +64 -63
  136. package/src/file-safe-name.ts +32 -0
  137. package/src/formation-approval.ts +604 -610
  138. package/src/fs-atomic.ts +92 -117
  139. package/src/index.ts +71 -4
  140. package/src/invitation-bootstrap.ts +91 -13
  141. package/src/joined-strand-store.ts +508 -498
  142. package/src/key-store-file.ts +142 -141
  143. package/src/link-budget.ts +9 -4
  144. package/src/machine-removal.ts +216 -0
  145. package/src/membership-connection-gater.ts +638 -637
  146. package/src/node-claim-payload.ts +138 -4
  147. package/src/node-default-transports.ts +38 -0
  148. package/src/node-transports.ts +49 -0
  149. package/src/peer-dial.ts +604 -336
  150. package/src/peer-join-backfill.ts +6 -0
  151. package/src/peer-record.ts +159 -202
  152. package/src/primary-lan-address.ts +47 -0
  153. package/src/provider/client.ts +326 -0
  154. package/src/provider/descriptor.ts +129 -0
  155. package/src/provider/index.ts +59 -0
  156. package/src/provider/oauth.ts +285 -0
  157. package/src/provider/rent-node.ts +275 -0
  158. package/src/relay-addrs.ts +9 -4
  159. package/src/relay-reservation.ts +1211 -1121
  160. package/src/seed-bootstrap.ts +1502 -1471
  161. package/src/strand-commit-durability.ts +116 -0
  162. package/src/strand-database.ts +46 -14
  163. package/src/strand-first-sync-gate.ts +7 -1
  164. package/src/strand-formation-protocol.ts +1039 -1036
  165. package/src/strand-fret-addrs.ts +179 -145
  166. package/src/strand-instance-manager.ts +2026 -1883
  167. package/src/strand-move.ts +585 -0
  168. package/src/strand-network-config.ts +7 -6
  169. package/src/strand-peer-redial.ts +307 -0
  170. package/src/strand-wake-protocol.ts +10 -7
  171. package/src/types.ts +220 -4
@@ -22,22 +22,23 @@ import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
22
22
  import { JoinedStrandSession, KeyStoreJoinedStrandStore, MemoryJoinedStrandStore } from './joined-strand-store.js';
23
23
  import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
24
24
  import { strandFretPeerAddrs } from './strand-fret-addrs.js';
25
- import { verifyCadrePeerVoucher, verifyInvitationAdmission } from './peer-authorization.js';
25
+ import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
26
26
  import { deriveOwnerAnchor } from './owner-anchor-sync.js';
27
+ import { describeMachines, planMachineRemoval, revouchWithinPlan, MachineRemovalRefusedError } from './machine-removal.js';
27
28
  import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
28
- import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
29
+ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
29
30
  import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
30
31
  import { StrandWatcher } from './strand-watcher.js';
31
32
  import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manager.js';
32
33
  import { PeerJoinBackfill } from './peer-join-backfill.js';
33
34
  import { deriveCohortMembers } from './strand-cohort.js';
34
35
  import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
35
- import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
36
+ import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS, trailingPeerId, withTrailingPeerId } from './peer-dial.js';
36
37
  import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
37
38
  import { EnrollmentService } from './enrollment.js';
38
39
  import { HibernationManager } from './hibernation-manager.js';
39
40
  import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
40
- import { CadreInviteHandler, encodeCadreInvitation, redeemAtMembers, signRedeemRequest } from './cadre-invite-protocol.js';
41
+ import { CadreInviteHandler, encodeCadreInvitation, redeemInPasses, signRedeemRequest } from './cadre-invite-protocol.js';
41
42
  import { FormationPostApprovalError } from './strand-formation-rejection.js';
42
43
  import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
43
44
  import { SeedBootstrapService, mergeSeedPeers, seedDigest } from './seed-bootstrap.js';
@@ -45,13 +46,14 @@ import { claimSecretTrustPolicy } from './seed-trust-policy.js';
45
46
  import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
46
47
  import { StrandSolicitationService } from './strand-solicitation.js';
47
48
  import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
48
- import { selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
49
+ import { cadreInvitationReach, selectCadreInvitationMembers, selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
49
50
  import { createMembershipConnectionGater, UnauthorizedReservationBudget } from './membership-connection-gater.js';
50
51
  import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
51
52
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
52
53
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
53
54
  import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey } from './delegate-admission.js';
54
55
  import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
56
+ import { nodeTransportsOption } from './node-transports.js';
55
57
  import { resolveRelayServer } from './relay-server.js';
56
58
  import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
57
59
  import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
@@ -259,8 +261,9 @@ export class CadreNode {
259
261
  this.controlBackfill = null;
260
262
  /**
261
263
  * Devices whose invitation redemption this member has verified and is admitting right now,
262
- * each with its count of overlapping redemptions. The control backfill's gate admits them
263
- * as it admits a member, for that window only ({@link catchUpRedeemingDevice}).
264
+ * each with its count of overlapping redemptions. The control backfill's gate and the
265
+ * control cohort choice (Optimystic's `isNetworkMember`) count them as members, for that
266
+ * window only ({@link admitRedeemingDevice}); the per-stream gate never does.
264
267
  */
265
268
  this.admittingDevices = new Map();
266
269
  this.strandWatcher = null;
@@ -307,6 +310,8 @@ export class CadreNode {
307
310
  this.pushFanoutService = null;
308
311
  /** Backing field for the {@link running} / {@link isRunning} getters. */
309
312
  this._running = false;
313
+ /** The {@link stop} in progress, or null; a concurrent `stop()` awaits it. */
314
+ this.stopInFlight = null;
310
315
  /**
311
316
  * The control database's own raw storage (cache-wrapped), resolved once per
312
317
  * `start()` and released in {@link cleanup}. Owning it is what keeps the
@@ -404,13 +409,15 @@ export class CadreNode {
404
409
  this.bootstrapPeerIds = null;
405
410
  /**
406
411
  * Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
407
- * {@link listAuthorizedMembers} result), consulted by the per-stream
408
- * control-DB gate ({@link authorizeInboundControlStream}). That predicate
409
- * runs on EVERY inbound Optimystic control-DB stream and must never await a
410
- * control-DB read: those reads pull blocks over the very protocols the
411
- * predicate gates, which closes a circular wait that ends in mutual denial
412
- * (the upstream gate is fail-closed on timeout, unlike the fail-open
413
- * connection gater). So the set is refreshed OUT OF BAND instead — after
412
+ * {@link listAuthorizedMembers} result), consulted through
413
+ * {@link isControlNetworkMember} by the per-stream control-DB gate
414
+ * ({@link authorizeInboundControlStream}) and by Optimystic's cohort choice
415
+ * (`isNetworkMember`). The stream gate runs on EVERY inbound Optimystic
416
+ * control-DB stream and must never await a control-DB read: those reads pull
417
+ * blocks over the very protocols the predicate gates, which closes a circular
418
+ * wait that ends in mutual denial (the upstream gate is fail-closed on
419
+ * timeout, unlike the fail-open connection gater). Optimystic's predicate is
420
+ * synchronous by contract. So the set is refreshed OUT OF BAND instead — after
414
421
  * start, on every {@link reconcileControlCohort} pass (15s cadence), and
415
422
  * immediately after every LOCAL membership mutation so a just-vouched peer
416
423
  * is admitted without waiting for the timer. That last refresh is AUTOMATIC:
@@ -419,7 +426,11 @@ export class CadreNode {
419
426
  * writer has to remember. A change that arrives by REPLICATION is picked up
420
427
  * on the next timed refresh — bounded staleness, acceptable because this gate
421
428
  * is defense in depth: rows an unadmitted peer manages to write are still
422
- * disbelieved at read time by the voucher-anchored predicate.
429
+ * disbelieved at read time by the voucher-anchored predicate. For cohort
430
+ * choice the same window means a member whose row arrived by replication is
431
+ * left out of this node's control-block cohorts until that refresh.
432
+ * Optimystic reads the predicate live on every lookup, so replacing this set
433
+ * is the whole re-evaluation; there is no per-peer reclassify call to make.
423
434
  */
424
435
  this.authorizedControlPeers = new Set();
425
436
  /**
@@ -431,6 +442,17 @@ export class CadreNode {
431
442
  this.membershipGateDirty = false;
432
443
  this.membershipGateDrain = null;
433
444
  this.membershipGateDeferDepth = 0;
445
+ /**
446
+ * This node's own `CadrePeer` row as a membership refresh last read it live, or null until
447
+ * one has. Reporting a removal needs it ({@link noteOwnRemoval}). Kept across
448
+ * stop()→start(), like {@link removal}.
449
+ */
450
+ this.ownLiveRow = null;
451
+ /**
452
+ * The removal this node learned of ({@link removedFromCadre}), or null. Set once and never
453
+ * cleared, so `cadre:removed` fires once per node object.
454
+ */
455
+ this.removal = null;
434
456
  /**
435
457
  * In-memory delegate admission grants (see `delegate-admission.ts`): the
436
458
  * strand-node transport peerIds this party's members have announced over the
@@ -673,9 +695,11 @@ export class CadreNode {
673
695
  * When this node last PUBLISHED its own `CadrePeer` address record (inserted or
674
696
  * refreshed), or null if it never has in this session. Stamped by
675
697
  * {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
676
- * from — the boot pass, the heartbeat, an address change, a drain or an explicit call.
698
+ * from — the boot pass, the heartbeat, an address change, a drain, a reconcile pass or an
699
+ * explicit call.
677
700
  *
678
- * Read by {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
701
+ * Read by {@link publishFirstSelfRecord}, which stops trying once it is set, and by
702
+ * {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
679
703
  * attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
680
704
  * machine in the party is already discarding this node's address as stale.
681
705
  */
@@ -1039,7 +1063,7 @@ export class CadreNode {
1039
1063
  this.cadreInviteHandler = new CadreInviteHandler({
1040
1064
  partyId: this.config.controlNetwork.partyId,
1041
1065
  store: this.controlDatabase,
1042
- catchUpDevice: (peerId) => this.catchUpRedeemingDevice(peerId),
1066
+ admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
1043
1067
  });
1044
1068
  await this.cadreInviteHandler.register(this.controlNode);
1045
1069
  // A node waiting to be claimed listens for its claim seed from the start: nobody
@@ -1128,10 +1152,26 @@ export class CadreNode {
1128
1152
  * Stop the cadre node
1129
1153
  */
1130
1154
  async stop() {
1155
+ // A second call while one is in flight joins it rather than running cleanup() twice
1156
+ // concurrently: `_running` stays true until cleanup() ends, and an event raised by a
1157
+ // refresh still in flight (`cadre:removed`, whose embedder stops the node) can reach a
1158
+ // handler that calls stop() mid-teardown.
1159
+ if (this.stopInFlight) {
1160
+ return this.stopInFlight;
1161
+ }
1131
1162
  if (!this._running) {
1132
1163
  log('CadreNode not running');
1133
1164
  return;
1134
1165
  }
1166
+ this.stopInFlight = this.stopRunning();
1167
+ try {
1168
+ await this.stopInFlight;
1169
+ }
1170
+ finally {
1171
+ this.stopInFlight = null;
1172
+ }
1173
+ }
1174
+ async stopRunning() {
1135
1175
  log('Stopping CadreNode');
1136
1176
  await this.cleanup();
1137
1177
  this.emit('control:disconnected', undefined);
@@ -1301,7 +1341,7 @@ export class CadreNode {
1301
1341
  * {@link start} has built the anchor.
1302
1342
  *
1303
1343
  * While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
1304
- * and refuses their control-database streams ({@link authorizeInboundControlStream})
1344
+ * and refuses them control-database streams and cohort places ({@link isControlNetworkMember})
1305
1345
  * and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
1306
1346
  * no siblings to replicate from and must not become a public relay while it waits.
1307
1347
  * cadre-cli reports this on `/status`; `claim:accepted` is the event form.
@@ -1309,6 +1349,15 @@ export class CadreNode {
1309
1349
  isAwaitingClaim() {
1310
1350
  return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
1311
1351
  }
1352
+ /**
1353
+ * The removal from the cadre this node has learned of, or null: the query form of
1354
+ * `cadre:removed`, for an embedder that subscribed after it fired. Kept once set, across
1355
+ * stop()→start() and a later re-admission, because the embedder acts on it once (stops this
1356
+ * node, deletes the cadre's data); a re-added machine joins again from a fresh node.
1357
+ */
1358
+ removedFromCadre() {
1359
+ return this.removal;
1360
+ }
1312
1361
  /**
1313
1362
  * Construct (or adopt) the node-local cold-start bootstrap-peer store (see
1314
1363
  * {@link bootstrapPeerStore}). Synchronous: an injected store has already
@@ -1433,6 +1482,38 @@ export class CadreNode {
1433
1482
  getStrandNetworkStateStore() {
1434
1483
  return this.strandNetworkStateStore;
1435
1484
  }
1485
+ /**
1486
+ * The strands this machine joined from another party and has not published party-wide
1487
+ * (see {@link CadreNodeConfig.joinedStrands}). Publication needs a control connection,
1488
+ * so a one-node party's joins live here until it holds one; `exportStrandBundle` reads
1489
+ * it beside the `JoinedStrand` table.
1490
+ *
1491
+ * @throws when the node is not started.
1492
+ */
1493
+ listUnpublishedJoinedStrands() {
1494
+ if (!this.joinedStrands) {
1495
+ throw new Error('CadreNode must be started before listing its unpublished joined strands');
1496
+ }
1497
+ return this.joinedStrands.unpublishedRecords();
1498
+ }
1499
+ /**
1500
+ * The strand-network addresses a formation carried for `strandId` (see
1501
+ * {@link formationStrandAddrs}), as kept: attributed per peer. Empty when none were
1502
+ * carried or the strand's peers were forgotten. A copy; the node keeps its own list.
1503
+ */
1504
+ getCarriedStrandAddrs(strandId) {
1505
+ return [...(this.formationStrandAddrs.get(strandId) ?? [])];
1506
+ }
1507
+ /**
1508
+ * Seat `addrs` as the addresses a formation carried for `strandId`, exactly as a
1509
+ * formation of this node's own would ({@link recordFormationStrandAddrs}): a strand
1510
+ * move seats the exporting node's list here so the first launch has a cross-party
1511
+ * seed. The usual rules apply: a list naming a peer replaces the strand's earlier
1512
+ * one, and a list naming none records nothing.
1513
+ */
1514
+ recordCarriedStrandAddrs(strandId, addrs) {
1515
+ return this.recordFormationStrandAddrs(strandId, addrs, 'recordCarriedStrandAddrs');
1516
+ }
1436
1517
  /**
1437
1518
  * The node-local trusted-owner anchor (null before {@link start}). This is
1438
1519
  * the set the authorized-membership predicate and seed-trust anchor consult —
@@ -1516,7 +1597,7 @@ export class CadreNode {
1516
1597
  * connect-then-authorize — on every committed membership change via
1517
1598
  * {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
1518
1599
  * The one non-member the gate admits is a device whose invitation
1519
- * redemption this member is admitting ({@link catchUpRedeemingDevice}).
1600
+ * redemption this member is admitting ({@link admitRedeemingDevice}).
1520
1601
  *
1521
1602
  * No-ops (logged) when the embedder configured no control storage or the
1522
1603
  * node exposes no key network — the backfill would have nothing to read or
@@ -1573,29 +1654,50 @@ export class CadreNode {
1573
1654
  this.controlBackfill.start();
1574
1655
  }
1575
1656
  /**
1576
- * Push this member's control store to a device whose invitation redemption has verified,
1577
- * and wait for the push, before the redemption seats the invitation's row and writes the
1578
- * device's rows (the handler's `catchUpDevice`; `CadreInviteHandler.catchUpDeviceIfLive`
1579
- * says why those writes need it).
1580
- * The device is admitted at the backfill's gate for the length of the push only.
1657
+ * Admit a device whose invitation redemption has verified to this member's control
1658
+ * replication until the returned release is called (the handler's `admitDevice`;
1659
+ * `CadreInviteHandler.admitDeviceIfLive` says why the redemption's writes need it): push it
1660
+ * this member's control store and wait for the push, with the device counted as a member by
1661
+ * the backfill's gate and by the control cohort choice ({@link admittingDevices}), so the
1662
+ * seat and the admission that follow are written to the device too. A failed push releases
1663
+ * at once and rethrows: in the cohort without the store, the device would refuse the
1664
+ * commits and tear the writes. With no backfill on this node nothing is admitted.
1581
1665
  *
1582
1666
  * Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
1583
1667
  * `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
1584
1668
  * before the device was added here, and `catchUpPeer` would answer that run's in-flight
1585
1669
  * status with an empty result.
1586
1670
  */
1587
- async catchUpRedeemingDevice(peerId) {
1671
+ async admitRedeemingDevice(peerId) {
1588
1672
  const backfill = this.controlBackfill;
1589
1673
  if (!backfill) {
1590
- log('No control backfill on this node; device %s is not caught up before its admission', peerId);
1591
- return;
1592
- }
1593
- this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
1674
+ log('No control backfill on this node; device %s is neither caught up nor admitted before its admission', peerId);
1675
+ return () => undefined;
1676
+ }
1677
+ // NOTE: counted in before the push, because the backfill's gate must admit the device to
1678
+ // push to it; so for the push's length any other control write this member makes includes
1679
+ // a device without the base revisions, which refuses it and the write retries. If that
1680
+ // shows up as retried control writes during redemptions, count the device into the cohort
1681
+ // choice only once the push resolves (a second set beside this one).
1682
+ const release = this.countAdmittingDevice(peerId);
1594
1683
  try {
1595
1684
  const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
1596
1685
  log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d', peerId, result.offered, result.accepted, result.rejected.length);
1686
+ return release;
1597
1687
  }
1598
- finally {
1688
+ catch (error) {
1689
+ release();
1690
+ throw error;
1691
+ }
1692
+ }
1693
+ /** Count one more redemption admitting `peerId`; the returned release undoes it, once. */
1694
+ countAdmittingDevice(peerId) {
1695
+ this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
1696
+ let released = false;
1697
+ return () => {
1698
+ if (released)
1699
+ return;
1700
+ released = true;
1599
1701
  const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
1600
1702
  if (remaining > 0) {
1601
1703
  this.admittingDevices.set(peerId, remaining);
@@ -1603,7 +1705,7 @@ export class CadreNode {
1603
1705
  else {
1604
1706
  this.admittingDevices.delete(peerId);
1605
1707
  }
1606
- }
1708
+ };
1607
1709
  }
1608
1710
  /**
1609
1711
  * The control database's cache-wrapped raw storage, resolved from
@@ -1747,7 +1849,13 @@ export class CadreNode {
1747
1849
  }),
1748
1850
  arachnode: { enableRingZulu: profile === 'storage' },
1749
1851
  ...(identityKey && { privateKey: identityKey }),
1750
- ...(network?.transports && { transports: network.transports }),
1852
+ // With `network.relayAddrs` set, built with libp2p's relay discovery off (given a
1853
+ // transport list to wrap — `node-transports.ts`), so the supervisor
1854
+ // `driveControlRelayReservation` starts is the only thing that fills the search slot:
1855
+ // otherwise a member's control node running the relay server, which this node
1856
+ // identifies (same party namespace), can take it first (`relay-reservation.ts`,
1857
+ // "WHY LIBP2P'S RELAY DISCOVERY IS SWITCHED OFF").
1858
+ ...nodeTransportsOption(network, transportOptions, (network?.relayAddrs ?? []).length > 0),
1751
1859
  ...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
1752
1860
  // Always present, unlike the conditional spreads around it: an absent
1753
1861
  // `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
@@ -1766,7 +1874,9 @@ export class CadreNode {
1766
1874
  ...optimysticDialLimits(network?.linkRoundTripMs),
1767
1875
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1768
1876
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1769
- // then. Spread NEXT to `transports` because the two answer the same question.
1877
+ // then. Spread NEXT to `transports` because the two answer the same question, and a
1878
+ // no-op whenever a list is passed there — with `relayAddrs` set that list comes from
1879
+ // `network.defaultTransports` when the embedder gave none, on the same switch.
1770
1880
  ...transportOptions,
1771
1881
  // Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
1772
1882
  // `network.relayAddrs` is set — that listener registers the pending reservation
@@ -1796,7 +1906,14 @@ export class CadreNode {
1796
1906
  // (installControlProtocolGuard) checks these four with this same predicate
1797
1907
  // first; Optimystic's own check is kept because it is a working, supported
1798
1908
  // upstream defence, not a second policy.
1799
- authorizeInboundStream: (remotePeerId, protocol) => this.authorizeInboundControlStream(remotePeerId, protocol)
1909
+ authorizeInboundStream: (remotePeerId, protocol) => this.authorizeInboundControlStream(remotePeerId, protocol),
1910
+ // Who this node SENDS control writes to (cohort and coordinator choice), where
1911
+ // `authorizeInboundStream` is who may send to it. Without it Optimystic picks cohort
1912
+ // members from the protocols a peer's peer-store record lists, which any connected
1913
+ // peer can fill. The stream gate's rule, unlogged (Optimystic asks several times per
1914
+ // peer per cohort assembly and counts refusals on its own debug line), plus a device
1915
+ // whose verified redemption is being written, which must hold the rows that admit it.
1916
+ isNetworkMember: (peerId) => this.isControlNetworkMember(peerId) || this.admittingDevices.has(peerId)
1800
1917
  };
1801
1918
  return nodeOptions;
1802
1919
  }
@@ -2027,13 +2144,17 @@ export class CadreNode {
2027
2144
  return this.getBootstrapPeerIds().has(remotePeerId);
2028
2145
  }
2029
2146
  /**
2030
- * Per-stream authorization for the protocol guard's `members-snapshot` class
2031
- * (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
2032
- * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
2033
- * five, and any protocol nobody classed. Also wired as Optimystic's
2034
- * `authorizeInboundStream` in {@link createControlNode} for the four DB
2035
- * protocols. The fail-closed layer behind the fail-open connection gater.
2036
- * Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
2147
+ * The membership rule this control node applies to a remote peer, without logging. Two
2148
+ * consumers: the per-stream gate ({@link authorizeInboundControlStream}, who may SEND to
2149
+ * this node on a members-only protocol) and Optimystic's `isNetworkMember` (who this node
2150
+ * sends control writes TO — which peers can be a control block's cohort member or
2151
+ * coordinator), which adds only a device whose verified invitation redemption is being
2152
+ * written ({@link admittingDevices}). One rule for both keeps the outbound choice as
2153
+ * permissive as the inbound acceptance: a joining node's cohort must include the inviter
2154
+ * its empty anchor or empty snapshot lets replicate to it, or it would see a self-only
2155
+ * cohort, rule an existing collection absent and found a second copy. Sereus builds the
2156
+ * control node with `bootstrapNodes: []`, so Optimystic's refused-bootstrap-peer guard
2157
+ * does not cover that.
2037
2158
  *
2038
2159
  * The STRICT SUBSET of {@link admitInboundControlConnection}: the same
2039
2160
  * "no basis to judge" admissions (shared via
@@ -2042,57 +2163,74 @@ export class CadreNode {
2042
2163
  * grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
2043
2164
  * and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
2044
2165
  * stranger to `repo` while an invitation is live is exactly the hole this
2045
- * gate closes. A DELEGATE-admitted connection (a member's strand node
2166
+ * rule closes. A DELEGATE-admitted connection (a member's strand node
2046
2167
  * holding a circuit-relay reservation, see `delegate-admission.ts`) is
2047
- * likewise exactly a case this gate must still refuse: the delegate gets
2048
- * the connection and never the control DB.
2168
+ * likewise refused: the delegate gets the connection and never the control DB.
2049
2169
  *
2050
2170
  * Refuses everyone, ahead of every admission below, while this node is waiting to be
2051
2171
  * claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
2052
2172
  * exist so a brand-new node can take replication from the siblings that will
2053
2173
  * authorize it; an unclaimed node has no siblings yet, and the rows that will
2054
2174
  * authorize anyone arrive only after the claim, over the owner's streams that the
2055
- * same two admissions then let in. Configured bootstrap infra is refused too: a
2056
- * relay opens no control-database stream.
2175
+ * same two admissions then let in. Its cohort is itself alone meanwhile, which costs
2176
+ * nothing: it built its control database with zero connections. Configured bootstrap
2177
+ * infra is refused too: a relay opens no control-database stream.
2057
2178
  *
2058
2179
  * Otherwise admits when ANY of:
2059
2180
  * 1. a shared-baseline check admits (not running / no DB, empty anchor,
2060
- * configured bootstrap infra);
2181
+ * configured bootstrap infra — which Optimystic still classifies `foreign`,
2182
+ * since a relay serves none of the party's database protocols);
2061
2183
  * 2. the materialized authorized set is empty — cold start, before the
2062
2184
  * rows that would authorize anyone have replicated in;
2063
2185
  * 3. the peer IS in the materialized authorized set.
2064
2186
  *
2065
2187
  * Deliberately SYNCHRONOUS and pure in-memory — see
2066
2188
  * {@link authorizedControlPeers} for why a control-DB read here would
2067
- * deadlock into mutual denial. With a sync predicate the upstream deadline
2068
- * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
2069
- * default.
2189
+ * deadlock into mutual denial.
2070
2190
  *
2071
2191
  * NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
2072
2192
  * key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
2073
- * siblings deny its control-DB streams once their own snapshot is non-empty —
2074
- * owner status is a KEY, not a peer id, so being the owner does not exempt it.
2075
- * Harmless today (a real deployment persists its identity; the ephemeral-owner
2076
- * case appears only in tests, which configure the owner as bootstrap infra —
2077
- * `push-wake-e2e.integration.ts` design note 3). If an ephemeral-identity node
2078
- * ever becomes a supported deployment, this gate needs a key-based admission.
2079
- */
2080
- authorizeInboundControlStream(remotePeerId, protocol) {
2193
+ * siblings deny its control-DB streams, and leave it out of their cohorts, once
2194
+ * their own snapshot is non-empty — owner status is a KEY, not a peer id, so
2195
+ * being the owner does not exempt it. Harmless today (a real deployment persists
2196
+ * its identity; the ephemeral-owner case appears only in tests, which configure
2197
+ * the owner as bootstrap infra — `push-wake-e2e.integration.ts` design note 3).
2198
+ * If an ephemeral-identity node ever becomes a supported deployment, this rule
2199
+ * needs a key-based admission.
2200
+ */
2201
+ isControlNetworkMember(remotePeerId) {
2081
2202
  if (this.isAwaitingClaim()) {
2082
- log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is', remotePeerId, protocol);
2083
2203
  return false;
2084
2204
  }
2085
2205
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
2086
2206
  return true;
2087
2207
  }
2088
- if (this.authorizedControlPeers.size === 0) {
2089
- return true;
2090
- }
2091
- if (this.authorizedControlPeers.has(remotePeerId)) {
2208
+ return this.authorizedControlPeers.size === 0 || this.authorizedControlPeers.has(remotePeerId);
2209
+ }
2210
+ /**
2211
+ * Per-stream authorization for the protocol guard's `members-snapshot` class
2212
+ * (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
2213
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
2214
+ * five, and any protocol nobody classed. Also wired as Optimystic's
2215
+ * `authorizeInboundStream` in {@link createControlNode} for the four DB
2216
+ * protocols. The fail-closed layer behind the fail-open connection gater.
2217
+ * Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
2218
+ *
2219
+ * Admits exactly the peers {@link isControlNetworkMember} does, and logs why it
2220
+ * refused one. With a sync predicate the upstream deadline
2221
+ * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its default.
2222
+ */
2223
+ authorizeInboundControlStream(remotePeerId, protocol) {
2224
+ if (this.isControlNetworkMember(remotePeerId)) {
2092
2225
  return true;
2093
2226
  }
2094
2227
  // Upstream logs its own denial line; this one carries the WHY.
2095
- log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))', remotePeerId, protocol, this.authorizedControlPeers.size);
2228
+ if (this.isAwaitingClaim()) {
2229
+ log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is', remotePeerId, protocol);
2230
+ }
2231
+ else {
2232
+ log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))', remotePeerId, protocol, this.authorizedControlPeers.size);
2233
+ }
2096
2234
  return false;
2097
2235
  }
2098
2236
  /**
@@ -2112,7 +2250,9 @@ export class CadreNode {
2112
2250
  *
2113
2251
  * The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
2114
2252
  * built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
2115
- * has it. The invitation chain both reads may need is loaded at most once per refresh.
2253
+ * has it. The invitation chain both reads may need, and the tombstone list the sync and
2254
+ * the two end-of-refresh checks ({@link hangUpRevokedControlPeers}, then
2255
+ * {@link noteOwnRemoval}) read, are each loaded at most once per refresh.
2116
2256
  */
2117
2257
  async refreshAuthorizedControlPeers(reason) {
2118
2258
  if (!this._running || !this.controlDatabase) {
@@ -2120,8 +2260,10 @@ export class CadreNode {
2120
2260
  }
2121
2261
  try {
2122
2262
  const loadChain = this.invitationChainSource(this.controlDatabase, false);
2123
- await this.syncOwnerAnchor(reason, false, loadChain);
2124
- const members = await this.collectAuthorizedMembers(this.controlDatabase, false, loadChain);
2263
+ const loadTombstones = this.revocationSource(this.controlDatabase, false);
2264
+ await this.syncOwnerAnchor(reason, false, loadChain, loadTombstones);
2265
+ const rows = await this.controlDatabase.queryCadrePeers(false);
2266
+ const members = this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
2125
2267
  this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
2126
2268
  log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
2127
2269
  // Remember the party's size for the NEXT launch's control-node repair yardstick
@@ -2188,11 +2330,136 @@ export class CadreNode {
2188
2330
  // re-arming peers whose last pass was denied under the SAME membership
2189
2331
  // snapshot instead of re-arming unconditionally.
2190
2332
  void this.controlBackfill?.scheduleConnectedPeers();
2333
+ await this.hangUpRevokedControlPeers(rows, loadTombstones);
2334
+ await this.noteOwnRemoval(rows, loadTombstones);
2191
2335
  }
2192
2336
  catch (error) {
2193
2337
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
2194
2338
  }
2195
2339
  }
2340
+ /**
2341
+ * Hang up this node's control connections to every peer that a `CadrePeer` tombstone names
2342
+ * and that has no live `CadrePeer` row: a machine removed from the cadre, whose streams the
2343
+ * protocol guard already refuses but whose open connection would otherwise stay up until it
2344
+ * dropped on its own. Runs at the end of every membership refresh: on the remover from the
2345
+ * removal's own membership notification, so the connection is closed before
2346
+ * {@link removeMachine} returns; on every other machine from the refresh that follows the
2347
+ * replicated tombstone.
2348
+ *
2349
+ * Denies only on evidence held, the strand enforcer's rule (`strand-revocation-enforcer.ts`):
2350
+ * a peer that merely dropped out of the authorized snapshot (an anchor read that failed, a
2351
+ * voucher whose owner is gone) keeps its connection, self is never hung up, and a peer with a
2352
+ * tombstone AND a live row under a fresh stamp was re-admitted and keeps its connection too.
2353
+ * The tombstones are the refresh's own read (`loadTombstones`, shared with
2354
+ * {@link syncOwnerAnchor}), taken only when some connected peer has no live row, so a cadre
2355
+ * with every connection accounted for costs nothing here. A failed `hangUp` is logged and
2356
+ * retried by the next refresh. Never rejects.
2357
+ */
2358
+ async hangUpRevokedControlPeers(rows, loadTombstones) {
2359
+ const node = this.controlNode;
2360
+ if (!node) {
2361
+ return;
2362
+ }
2363
+ const selfPeerId = node.peerId.toString();
2364
+ const live = new Set(rows.map((row) => row.peerId));
2365
+ const rowless = new Map();
2366
+ for (const connection of node.getConnections()) {
2367
+ const remote = connection.remotePeer.toString();
2368
+ if (remote !== selfPeerId && !live.has(remote)) {
2369
+ rowless.set(remote, connection.remotePeer);
2370
+ }
2371
+ }
2372
+ if (rowless.size === 0) {
2373
+ return;
2374
+ }
2375
+ // NOTE: on a node whose anchor is empty the anchor sync skips its tombstone read, so a
2376
+ // connection to a peer with no row (a configured relay or bootstrap peer) makes this the
2377
+ // refresh's one `Revocation` read; bounded at one per refresh. If an un-enrolled node's
2378
+ // refresh ever shows up in the consult budget, skip this read while the anchor is empty.
2379
+ let revoked;
2380
+ try {
2381
+ revoked = new Set((await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer').map((tombstone) => tombstone.rowKey));
2382
+ }
2383
+ catch (error) {
2384
+ log('membership refresh: tombstone read for the hang-up of removed peers failed; next refresh retries: %o', error);
2385
+ return;
2386
+ }
2387
+ // NOTE: awaited inside the membership listener, so under the control database's write
2388
+ // lock; libp2p bounds each connection's close at its 1 s `connectionCloseTimeout`, so a
2389
+ // silent peer holds local writes for at most that. If several removed peers ever pile up
2390
+ // here, hang them up concurrently or move the call after the listener returns.
2391
+ for (const [remote, peer] of rowless) {
2392
+ if (!revoked.has(remote)) {
2393
+ continue;
2394
+ }
2395
+ try {
2396
+ await node.hangUp(peer);
2397
+ log('membership refresh: hung up %s (its CadrePeer row is tombstoned and no live row replaced it)', remote);
2398
+ }
2399
+ catch (error) {
2400
+ log('membership refresh: hangUp of removed peer %s failed; next refresh retries: %o', remote, error);
2401
+ }
2402
+ }
2403
+ }
2404
+ /**
2405
+ * Learn that an owner removed this node from the cadre, at the end of every membership
2406
+ * refresh: a `CadrePeer` tombstone names this node's peer id, `rows` (the refresh's own
2407
+ * retired-stamp-filtered read) has no live row for it, and an earlier refresh of this process
2408
+ * saw one ({@link ownLiveRow}). Then it records {@link removal} and emits `cadre:removed`,
2409
+ * once. Nothing is stopped here: stopping and deleting are the embedder's, whose storage,
2410
+ * saved starts and process lifetime they are.
2411
+ *
2412
+ * The "seen live" condition keeps a machine joining again, whose fresh store can sync the old
2413
+ * tombstone before its new row, from reporting itself removed. A re-admission under a fresh
2414
+ * stamp is a live row, so a tombstone plus a live row is never a removal. The tombstones are
2415
+ * the refresh's shared read, taken only once the own row is missing. Never rejects.
2416
+ */
2417
+ async noteOwnRemoval(rows, loadTombstones) {
2418
+ const selfPeerId = this.controlNode?.peerId.toString();
2419
+ if (selfPeerId === undefined || this.removal !== null) {
2420
+ return;
2421
+ }
2422
+ const own = rows.find((row) => row.peerId === selfPeerId);
2423
+ if (own) {
2424
+ this.ownLiveRow = own;
2425
+ return;
2426
+ }
2427
+ // NOTE: a machine that synced its tombstone and restarted before its embedder acted never
2428
+ // reports, since the new process never saw its row live. The embedders persist what they
2429
+ // did (a marker, a deleted party), so the window is a crash between this event and that
2430
+ // action; if it is ever hit, persist the removal here.
2431
+ const lastLive = this.ownLiveRow;
2432
+ if (lastLive === null) {
2433
+ return;
2434
+ }
2435
+ let tombstones;
2436
+ try {
2437
+ tombstones = (await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer' && tombstone.rowKey === selfPeerId);
2438
+ }
2439
+ catch (error) {
2440
+ log('membership refresh: tombstone read for this node\'s own removal failed; next refresh retries: %o', error);
2441
+ return;
2442
+ }
2443
+ if (tombstones.length === 0 || !this._running || !this.controlDatabase) {
2444
+ return;
2445
+ }
2446
+ const tombstone = tombstones.find((candidate) => candidate.stampId === lastLive.stampId) ?? tombstones[0];
2447
+ this.removal = { partyId: this.partyId, stampId: tombstone.stampId, signerKey: this.anchoredRevocationSigner(tombstone), at: Date.now() };
2448
+ log('membership refresh: this node was removed from cadre %s (tombstone %s, signer %s)', this.partyId, tombstone.stampId, tombstone.signerKey);
2449
+ this.emit('cadre:removed', this.removal);
2450
+ }
2451
+ /**
2452
+ * The tombstone's signer when its signature verifies and the key is in this node's
2453
+ * trusted-owner anchor, else null. A tombstone that fails this is still a removal (every
2454
+ * other machine honours it, and this node's own row is gone), so it is logged, not refused.
2455
+ */
2456
+ anchoredRevocationSigner(tombstone) {
2457
+ if (verifyRevocationSigner(this.partyId, tombstone) && this.trustedOwnerStore?.has(tombstone.signerKey)) {
2458
+ return tombstone.signerKey;
2459
+ }
2460
+ log('membership refresh: the tombstone removing this node (%s) is not signed by an owner anchored here (signer %s)', tombstone.stampId, tombstone.signerKey);
2461
+ return null;
2462
+ }
2196
2463
  /**
2197
2464
  * Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
2198
2465
  * and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
@@ -2323,12 +2590,31 @@ export class CadreNode {
2323
2590
  * reference apps), and a write it signed would only be refused.
2324
2591
  */
2325
2592
  async enrolledOwnerSigningKey() {
2326
- const signingKey = this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
2593
+ const signingKey = this.ownerSigningKeyCandidate();
2327
2594
  if (!signingKey || !this.controlDatabase) {
2328
2595
  return null;
2329
2596
  }
2330
2597
  return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
2331
2598
  }
2599
+ /**
2600
+ * Whether this node can act as an owner of its cadre: its identity key is wired as its
2601
+ * owner signing key ({@link initializeSeedBootstrap}) and enrolled in the live `OwnerKey`
2602
+ * set ({@link enrolledOwnerSigningKey}). What the owner-only flows (`createCadreInvitation`,
2603
+ * `claimNode`, `rentNode`) need; false before `start()`, and on a machine that joined as a
2604
+ * member. The reference apps' "is this device an owner" check.
2605
+ */
2606
+ async isOwner() {
2607
+ return (await this.enrolledOwnerSigningKey()) !== null;
2608
+ }
2609
+ /** {@link enrolledOwnerSigningKey} judged against an owner set the caller has already read. */
2610
+ ownerSigningKeyEnrolledIn(ownerKeys) {
2611
+ const signingKey = this.ownerSigningKeyCandidate();
2612
+ return signingKey !== null && ownerKeys.has(signingKey.publicKeyB64) ? signingKey : null;
2613
+ }
2614
+ /** This machine's identity key as an owner signing key, when its seed bootstrap is wired to sign; enrollment not yet checked. */
2615
+ ownerSigningKeyCandidate() {
2616
+ return this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
2617
+ }
2332
2618
  /**
2333
2619
  * Schedule the node's initial self-record publish + ongoing refresh shortly
2334
2620
  * after start (non-blocking). {@link registerSelf} is idempotent and safely
@@ -2342,12 +2628,11 @@ export class CadreNode {
2342
2628
  await this.registerSelf();
2343
2629
  }
2344
2630
  catch (error) {
2345
- // Background task — a failed publish must not crash the node.
2346
- // NOTE: a restarted machine whose own CadrePeer row replicates in while this update
2347
- // runs fails here with ConcurrentModificationError (seen on the owner's restart in
2348
- // every traced run of cadre-invite-any-member), leaving its record unrefreshed until
2349
- // the next heartbeat. If a restarted machine's changed addresses must publish sooner,
2350
- // retry once here.
2631
+ // Background task — a failed publish must not crash the node. A restarted machine
2632
+ // whose own CadrePeer row replicates in while this update runs fails here with
2633
+ // ConcurrentModificationError (seen on the owner's restart in every traced run of
2634
+ // cadre-invite-any-member); the next connected reconcile pass retries
2635
+ // (publishFirstSelfRecord).
2351
2636
  log('Self-registration failed: %o', error);
2352
2637
  }
2353
2638
  // Wire the ongoing refresh + control-cohort reconcile cadence, then run
@@ -2413,23 +2698,26 @@ export class CadreNode {
2413
2698
  return 'skipped';
2414
2699
  }
2415
2700
  const peerId = this.controlNode.peerId.toString();
2416
- const addrs = await this.collectSelfAddrs();
2417
2701
  const existing = await this.controlDatabase.queryPeerRecord(peerId);
2418
- if (!existing) {
2419
- // A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
2420
- // cannot sign the insert, so it is "no owner service" here too.
2421
- if (!this.seedBootstrapService?.canAuthorize()) {
2422
- // "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
2423
- // retired in Revocation as absent, so a removed node lands here every heartbeat
2424
- // and stops refreshing its own record — correct, but say so, or the message reads
2425
- // as "never added" for a node that was removed.
2426
- log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
2427
- return 'skipped';
2428
- }
2702
+ // A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
2703
+ // cannot sign the insert, so it is "no owner service" here too.
2704
+ const ownerService = this.seedBootstrapService?.canAuthorize() ? this.seedBootstrapService : null;
2705
+ if (!existing && !ownerService) {
2706
+ // "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
2707
+ // retired in Revocation as absent, so a removed node lands here every heartbeat
2708
+ // and stops refreshing its own record — correct, but say so, or the message reads
2709
+ // as "never added" for a node that was removed.
2710
+ log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
2711
+ return 'skipped';
2712
+ }
2713
+ // Collected only past that skip, which a node waiting for its row replicated in takes on
2714
+ // every connected reconcile pass ({@link publishFirstSelfRecord}).
2715
+ const addrs = await this.collectSelfAddrs();
2716
+ if (!existing && ownerService) {
2429
2717
  // First-time row: requires an owner signature (the node is its own
2430
2718
  // owner). insertSelfPeerRecord throws if no owner key is present.
2431
2719
  const record = this.signSelfRecord(peerId, signingKey, addrs, null);
2432
- if (await this.seedBootstrapService.insertSelfPeerRecord(record)) {
2720
+ if (await ownerService.insertSelfPeerRecord(record)) {
2433
2721
  if (this.committedAlone())
2434
2722
  this.pendingSelfPeerWrite = true;
2435
2723
  this.noteSelfRecordPublished();
@@ -2553,7 +2841,7 @@ export class CadreNode {
2553
2841
  */
2554
2842
  signSelfRecord(peerId, signingKey, addrs, existing) {
2555
2843
  const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
2556
- return signPeerRecord(this.partyId, { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
2844
+ return signPeerRecord(this.partyId, { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt, profile: this.config.profile }, signingKey.privateKeyB64);
2557
2845
  }
2558
2846
  /**
2559
2847
  * The ed25519 keypair (base64url) the node signs its own record with — the key
@@ -2629,25 +2917,39 @@ export class CadreNode {
2629
2917
  return ed25519KeyPairFromLibp2p(this.identityKey);
2630
2918
  }
2631
2919
  /**
2632
- * Publish this node's own address record from the reconcile pass, for a CLAIMED node that
2633
- * has not published one this session. Its owner writes its `CadrePeer` row only after the
2634
- * claim is accepted (`claimNode`), so the row reaches this node by replication, after the
2635
- * boot-time publish and the first-growth drain have both found nothing to publish; without
2636
- * this the record would wait for the heartbeat ({@link startRecordRefresh}), and the cadre
2637
- * could not resolve the node's addresses until then. The reconcile pass is where replicated
2638
- * membership is picked up, so each pass tries until the first publish lands. A try before
2639
- * the row has arrived is a `skipped` publish: one `CadrePeer` read. A claimed node later
2640
- * removed from its cadre never publishes again, so it pays that read on every pass.
2641
- */
2642
- async publishSelfRecordOnceClaimed() {
2643
- if (!this.config.claim || this.isAwaitingClaim() || this.lastSelfRecordPublishAt !== null) {
2920
+ * Publish this node's own address record from the reconcile pass until its first publish of
2921
+ * this session lands. A node with no owner key — seeded (`addDrone`, `cadre enroll add`),
2922
+ * claimed (`claimNode`) or otherwise authorized after it booted — cannot insert its own
2923
+ * `CadrePeer` row; the row an owner wrote for it reaches it by replication once the two
2924
+ * connect, usually after the boot-time publish and the first-growth drain have both found
2925
+ * nothing to publish. Without this the record would wait for the heartbeat
2926
+ * ({@link startRecordRefresh}), and the cadre could not resolve the node's addresses until
2927
+ * then. The reconcile pass is where replicated membership is picked up, so each pass tries.
2928
+ * It also retries an owner's boot-time publish that failed.
2929
+ *
2930
+ * A try before the row has arrived is a `skipped` publish: one `CadrePeer` read. Not tried
2931
+ * while the node waits to be claimed (it has no row and no party yet) or once it has learned
2932
+ * it was removed (nothing to publish). A removed node that never saw its own row live in this
2933
+ * process does not learn it was removed ({@link noteOwnRemoval}), and pays that read on every
2934
+ * pass.
2935
+ *
2936
+ * Connected-only: a row written elsewhere arrives over a connection, and an owner's publish
2937
+ * committed alone is local-only and forks its own history — a restarted owner whose embedder
2938
+ * runs a pass before it reconnects would write its row on the stale side of the fork, and
2939
+ * then never see rows written while it was down (`cadre-invite-any-member` runs that pass and
2940
+ * fails every time without this condition). The boot-time publish and the first-growth drain already try at the node's lone and
2941
+ * first-connection moments; this retries on the passes after.
2942
+ */
2943
+ async publishFirstSelfRecord() {
2944
+ if (this.lastSelfRecordPublishAt !== null || this.isAwaitingClaim() || this.removal !== null
2945
+ || this.getControlConnectionCount() === 0) {
2644
2946
  return;
2645
2947
  }
2646
2948
  const outcome = await this.registerSelf().catch((error) => {
2647
- log('reconcileControlCohort: claimed-node self-record publish failed (continuing): %o', error);
2949
+ log('reconcileControlCohort: first self-record publish failed (continuing): %o', error);
2648
2950
  return 'skipped';
2649
2951
  });
2650
- log('reconcileControlCohort: claimed node self-record publish: %s', outcome);
2952
+ log('reconcileControlCohort: first self-record publish: %s', outcome);
2651
2953
  }
2652
2954
  /**
2653
2955
  * Collect this node's current dialable addresses for publication, signaling
@@ -2690,25 +2992,21 @@ export class CadreNode {
2690
2992
  reconcile('self:peer:update');
2691
2993
  };
2692
2994
  this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
2693
- // NOTE: a node AUTHORIZED AFTER IT BOOTED can wait a whole heartbeat (7.5 min)
2694
- // to publish its address. `registerSelf` no-ops while a node has no readable
2695
- // `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
2696
- // wired here are the only ones after that: `self:peer:update` needs an ADDRESS
2697
- // CHANGE, and a relay-only node's addresses stop changing the moment its
2698
- // reservation lands — inside `start()`, before this listener exists (on the
2699
- // `network.requireRelay: false` posture the reservation may instead land LATER,
2700
- // from the retry supervisor, and then this listener is what publishes the
2701
- // `/p2p-circuit` address the boot-time publish could not carry). Harmless
2702
- // where a late-authorized node's addresses keep churning (NAT/relay rotation
2703
- // fire the event anyway), and invisible while parties authorize members before
2704
- // they boot. A claimed node is always authorized after it boots, and the reconcile
2705
- // pass publishes for it ({@link publishSelfRecordOnceClaimed}). If late enrollment
2706
- // becomes the normal path for other nodes too — an invited phone is exactly that —
2707
- // drop that method's claim condition rather than lengthening this list of triggers:
2708
- // the membership-change seam (`ControlDatabase.setMembershipChangeListener`) fires
2709
- // on LOCAL commits only, and the row a late-authorized node waits for is written
2710
- // elsewhere. Measured while writing `relay-only-control-addr.integration.ts` case 4,
2711
- // which drives `registerSelf()` explicitly because neither trigger is dependable there.
2995
+ // A node AUTHORIZED AFTER IT BOOTED (seeded, claimed, invited) gets its first
2996
+ // publish from the reconcile pass ({@link publishFirstSelfRecord}), not from the
2997
+ // two triggers wired here. `registerSelf` no-ops while a node has no readable
2998
+ // `CadrePeer` row, so its boot-time publish does nothing, and neither trigger is
2999
+ // dependable after that: `self:peer:update` needs an ADDRESS CHANGE, and a
3000
+ // relay-only node's addresses stop changing the moment its reservation lands —
3001
+ // inside `start()`, before this listener exists (on the `network.requireRelay:
3002
+ // false` posture the reservation may instead land LATER, from the retry
3003
+ // supervisor, and then this listener is what publishes the `/p2p-circuit` address
3004
+ // the boot-time publish could not carry); the heartbeat is 7.5 min away. Nor is
3005
+ // the membership-change seam (`ControlDatabase.setMembershipChangeListener`): it
3006
+ // fires on LOCAL commits only, and the row a late-authorized node waits for is
3007
+ // written elsewhere. Measured while writing `relay-only-control-addr.integration.ts`
3008
+ // case 4, which drives `registerSelf()` explicitly because neither trigger is
3009
+ // dependable there.
2712
3010
  this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
2713
3011
  this.recordRefreshTimer?.unref?.();
2714
3012
  const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
@@ -2863,8 +3161,9 @@ export class CadreNode {
2863
3161
  * ({@link bootstrapDialAddrs}).
2864
3162
  *
2865
3163
  * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
2866
- * `peerId`, so it does not belong in this peer's candidate list (libp2p's own
2867
- * dial queue filters the same shape out one layer lower).
3164
+ * `peerId`, so it does not belong in this peer's candidate list (`dialPeerAddrs`
3165
+ * binds every address to the peer it is given and drops the same shape one
3166
+ * layer lower; here it is dropped before it can reach the address book).
2868
3167
  */
2869
3168
  normalizeDialAddrs(addrs, peerId) {
2870
3169
  // Keyed by the NORMALIZED string, so two entries differing only by the suffix
@@ -2995,7 +3294,7 @@ export class CadreNode {
2995
3294
  if (!this._running || !this.controlNode || !this.controlDatabase) {
2996
3295
  return { dialed: [] };
2997
3296
  }
2998
- await this.publishSelfRecordOnceClaimed();
3297
+ await this.publishFirstSelfRecord();
2999
3298
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3000
3299
  return { dialed: [] };
3001
3300
  }
@@ -3347,7 +3646,7 @@ export class CadreNode {
3347
3646
  }
3348
3647
  try {
3349
3648
  log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
3350
- await dialPeerAddrs(controlNode, addrs, this.controlDialBudget(), `reconcileControlCohort dial of sibling ${sibling.peerId}`);
3649
+ await dialPeerAddrs(controlNode, sibling.peerId, addrs, this.controlDialBudget(), `reconcileControlCohort dial of sibling ${sibling.peerId}`);
3351
3650
  return true;
3352
3651
  }
3353
3652
  catch (error) {
@@ -3380,9 +3679,9 @@ export class CadreNode {
3380
3679
  *
3381
3680
  * The list may name transports this node cannot dial (a lent node reports TCP
3382
3681
  * and `/ws` addresses to a phone that dials WebSockets only). That needs no
3383
- * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
3384
- * rejects an address no transport can dial before touching the network, so
3385
- * such an address costs a log line.
3682
+ * filtering here: libp2p's transport manager rejects an address no transport
3683
+ * can dial before touching the network (`TransportUnavailableError`), so such
3684
+ * an address costs a log line.
3386
3685
  *
3387
3686
  * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
3388
3687
  * addresses one after another under a single deadline, and sorts loopback
@@ -3599,7 +3898,7 @@ export class CadreNode {
3599
3898
  }
3600
3899
  try {
3601
3900
  log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
3602
- await dialPeerAddrs(controlNode, parsed, this.controlDialBudget(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`);
3901
+ await dialPeerAddrs(controlNode, peerId, parsed, this.controlDialBudget(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`);
3603
3902
  return true;
3604
3903
  }
3605
3904
  catch (error) {
@@ -3815,7 +4114,10 @@ export class CadreNode {
3815
4114
  */
3816
4115
  async drainPendingRevocations() {
3817
4116
  const sweep = !this.reissuedHeldRevocations;
3818
- if (!sweep && this.pendingRevocations.size === 0) {
4117
+ // A removed machine's owner key went with its row (`removeMachine`), so the schema's
4118
+ // live-owner clause refuses every re-issue it could sign; reconstructAuthoredMembership
4119
+ // and drainPendingPeerWrites stop on the same test.
4120
+ if (this.removal !== null || (!sweep && this.pendingRevocations.size === 0)) {
3819
4121
  return;
3820
4122
  }
3821
4123
  if (!this._running || !this.controlNode || !this.controlDatabase) {
@@ -3842,12 +4144,10 @@ export class CadreNode {
3842
4144
  }
3843
4145
  }
3844
4146
  catch (error) {
3845
- // NOTE: a device whose owner key has been removed keeps running this drain (and the
3846
- // membership re-touches of reissuePendingPeerWrites / reissueAuthoredMembershipRows)
3847
- // on every growth edge, and once its own tombstone has reached it every attempt
3848
- // fails the schema's live-owner clause and lands here. Noise, not harm; a node that
3849
- // learns it has been revoked shutting itself down belongs to the device-removal plan
3850
- // ticket (remove-a-device-or-app-from-a-cadre).
4147
+ // NOTE: an owner whose key alone was removed (`removeOwner`, its machine still a member,
4148
+ // so not a removal this node reports) keeps running these drains, and every attempt
4149
+ // fails the schema's live-owner clause and lands here on each growth edge. Noise, not
4150
+ // harm; if it shows up, gate the drains on this node's key being a live owner.
3851
4151
  log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
3852
4152
  }
3853
4153
  }
@@ -3869,7 +4169,7 @@ export class CadreNode {
3869
4169
  * `CadrePeer` trace, but its tombstone is re-issuable).
3870
4170
  */
3871
4171
  async reconstructAuthoredMembership() {
3872
- if (!this.controlDatabase || !this.controlNode) {
4172
+ if (!this.controlDatabase || !this.controlNode || this.removal !== null) {
3873
4173
  return;
3874
4174
  }
3875
4175
  if (!this.seedBootstrapService?.canAuthorize()) {
@@ -3927,7 +4227,7 @@ export class CadreNode {
3927
4227
  * here.
3928
4228
  */
3929
4229
  async drainPendingPeerWrites() {
3930
- if (this.pendingPeerWrites.size === 0) {
4230
+ if (this.pendingPeerWrites.size === 0 || this.removal !== null) {
3931
4231
  return;
3932
4232
  }
3933
4233
  if (!this.seedBootstrapService?.canAuthorize()) {
@@ -5589,6 +5889,8 @@ export class CadreNode {
5589
5889
  // comes up `'syncing'` announces the moment its database is published.
5590
5890
  firstSync: this.config.strandFirstSync,
5591
5891
  onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
5892
+ onCommitDurability: (commit) => this.emit('strand:commit-durability', commit),
5893
+ onCommitHeld: (held) => this.emit('strand:commit-held', held),
5592
5894
  // Re-announce the delegate to ONE relay before the strand node's reservation
5593
5895
  // supervisor re-drives it (see announceDelegateToRelay). Retained with the
5594
5896
  // launch config, so a hibernation wake's rebuilt supervisors get it too.
@@ -6687,6 +6989,15 @@ export class CadreNode {
6687
6989
  *
6688
6990
  * Passing an empty list stops the supervisor and resets the posture to `none`
6689
6991
  * without dialing or waiting.
6992
+ *
6993
+ * NOTE: on a node with no `network.relayAddrs` (a browser tab with a hand-written
6994
+ * bare `/p2p-circuit`), libp2p's relay discovery stays on, because it was the only
6995
+ * thing filling that slot before this call. Such a tab connected to some other
6996
+ * relay-capable peer it identifies (a party member running the relay server) can
6997
+ * lose its one slot to that peer before this supervisor's request runs, the theft
6998
+ * `nodeTransportsOption` prevents on configured nodes (`relay-reservation.ts`). If a
6999
+ * tab is ever seen reserved through a relay it was not asked to use, build that
7000
+ * node through `withoutRelayDiscovery` too, or have the tab configure `relayAddrs`.
6690
7001
  */
6691
7002
  async reserveRelays(addrs, opts) {
6692
7003
  // Stop first, unconditionally: a second call with a different list must not
@@ -6790,18 +7101,23 @@ export class CadreNode {
6790
7101
  // — anchor it in the node-local store. The in-memory set updates
6791
7102
  // synchronously (see TrustedOwnerStore.trust); only the file persist is
6792
7103
  // fire-and-forget, logged on failure.
6793
- // NOTE: non-founder members that also wire seed-bootstrap with their own
6794
- // derived key (e.g. the phone joiner path in runOwnerGenesis, which needs
6795
- // it to self-publish its CadrePeer row) self-anchor a key that is not a
6796
- // party authority. The anchor sync (`syncOwnerAnchor`) does not correct this:
6797
- // a `genesis` entry is base, kept whether or not a live `OwnerKey` row vouches
6798
- // it. Harmless while such a node never mints an invite: the store does not
6799
- // replicate, so a node trusting itself grants nothing to others. But
6800
- // `createCadreInvitation` hands out the anchor's contents as the device's pins,
6801
- // so the moment a non-founder member mints an invitation it exports its own
6802
- // non-authority key as a cadre owner key. If that becomes reachable (today only
6803
- // cadre-cli/cadre-host owners mint invitations), gate this self-anchor on the
6804
- // actual OwnerKey genesis insert instead.
7104
+ // NOTE: wiring a key that is not a live owner here self-anchors a key that is
7105
+ // no party authority, and the anchor sync (`syncOwnerAnchor`) does not correct
7106
+ // it: a `genesis` entry is base, kept whether or not a live `OwnerKey` row
7107
+ // vouches it. A machine that joined by invitation reaches this only through
7108
+ // `wireOwnKeyIfOwner`, which checks the row first, so a joined non-owner never
7109
+ // self-anchors. Three callers still wire unconditionally on every start, so a
7110
+ // node of theirs started for a party founded elsewhere does: the phone kit's
7111
+ // `runOwnerGenesis` (cadre-rn; only for a party its saved start does not record
7112
+ // as joined, whose starts call `wireOwnKeyIfOwner` instead), the web reference
7113
+ // app's start (`cadre-web.ts`) and `cadre start --owner`. Contained while the store does
7114
+ // not replicate and such a node cannot mint: `createCadreInvitation` hands out
7115
+ // the anchor as the device's pins, but the `CadreInvite` insert needs a live
7116
+ // owner as issuer. If anything hands out the anchor without that check, gate
7117
+ // this self-anchor on a live `OwnerKey` row instead. A non-owner needs no
7118
+ // owner key to publish its own address record: once an owner or a redemption
7119
+ // has written its `CadrePeer` row, `registerSelf` refreshes it with the
7120
+ // identity key alone.
6805
7121
  if (this.trustedOwnerStore) {
6806
7122
  void this.trustedOwnerStore
6807
7123
  .trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
@@ -6894,6 +7210,11 @@ export class CadreNode {
6894
7210
  * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
6895
7211
  * never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
6896
7212
  * revoked peer is not addressable either.
7213
+ *
7214
+ * NOTE: answers the whole `CadrePeerRow`, past the two fields declared; the integration
7215
+ * harness (`owner-cli-node.ts`, `OwnerCliMemberRow.sig`) reads `sig` off it through the admin
7216
+ * channel to tell a row another member seated from one the subject published itself. Narrow
7217
+ * this to the declared fields only together with that reader.
6897
7218
  */
6898
7219
  async listMembers() {
6899
7220
  if (!this.controlDatabase) {
@@ -6982,9 +7303,20 @@ export class CadreNode {
6982
7303
  * source so the membership refresh can share one load with {@link syncOwnerAnchor}.
6983
7304
  */
6984
7305
  async collectAuthorizedMembers(controlDatabase, retry, loadChain) {
6985
- const selfPeerId = this.peerId?.toString();
6986
7306
  const rows = await controlDatabase.queryCadrePeers(retry);
6987
- const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
7307
+ return this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
7308
+ }
7309
+ /** The invitation chain, loaded only when some row in `rows` is invitation-admitted. */
7310
+ async chainFor(rows, loadChain) {
7311
+ return rows.some(isInvitationAdmitted) ? await loadChain() : null;
7312
+ }
7313
+ /**
7314
+ * The authorized members among `rows`, already read: checks 1 to 4 of
7315
+ * {@link listAuthorizedMembers}' predicate (check 5 is `queryCadrePeers`'s), so the membership
7316
+ * refresh can judge the rows it has to read anyway for the hang-up of removed peers.
7317
+ */
7318
+ authorizedAmong(rows, chain) {
7319
+ const selfPeerId = this.peerId?.toString();
6988
7320
  const authorized = rows
6989
7321
  .filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
6990
7322
  .map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
@@ -7045,7 +7377,8 @@ export class CadreNode {
7045
7377
  *
7046
7378
  * NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
7047
7379
  * tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
7048
- * read the retired set again), plus the invitation chain when some owner row is
7380
+ * read the retired set again, and which the hang-up of removed peers shares through
7381
+ * `loadTombstones`), plus the invitation chain when some owner row is
7049
7382
  * invitation-admitted — shared with the membership read through `loadChain`; the memo
7050
7383
  * suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
7051
7384
  * marker is filed that one read is a cohort consult per refresh, which
@@ -7053,8 +7386,9 @@ export class CadreNode {
7053
7386
  *
7054
7387
  * @param retry - forwarded to every read; `false` on the refresh path, for the reason on
7055
7388
  * {@link refreshAuthorizedControlPeers}.
7389
+ * @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
7056
7390
  */
7057
- async syncOwnerAnchor(reason, retry, loadChain) {
7391
+ async syncOwnerAnchor(reason, retry, loadChain, loadTombstones) {
7058
7392
  const store = this.trustedOwnerStore;
7059
7393
  const controlDatabase = this.controlDatabase;
7060
7394
  if (!store || !controlDatabase) {
@@ -7066,7 +7400,7 @@ export class CadreNode {
7066
7400
  }
7067
7401
  let target;
7068
7402
  try {
7069
- const tombstones = await controlDatabase.queryRevocations(retry);
7403
+ const tombstones = await loadTombstones();
7070
7404
  const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
7071
7405
  const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
7072
7406
  const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
@@ -7118,6 +7452,11 @@ export class CadreNode {
7118
7452
  let loading = null;
7119
7453
  return () => (loading ?? (loading = this.loadInvitationChain(controlDatabase, retry)));
7120
7454
  }
7455
+ /** A per-refresh {@link RevocationSource}: one `queryRevocations` read, shared by every caller in the pass. */
7456
+ revocationSource(controlDatabase, retry) {
7457
+ let loading = null;
7458
+ return () => (loading ?? (loading = controlDatabase.queryRevocations(retry)));
7459
+ }
7121
7460
  /**
7122
7461
  * The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
7123
7462
  * resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
@@ -7429,6 +7768,10 @@ export class CadreNode {
7429
7768
  * Remove a previously-authorized peer from the cadre.
7430
7769
  * Signs the peer ID with the owner key and deletes the CadrePeer row.
7431
7770
  *
7771
+ * Removes the `CadrePeer` row only. The call that removes a machine completely (its owner
7772
+ * key, its push token and the invitations it could come back through, in one transaction)
7773
+ * is {@link removeMachine}.
7774
+ *
7432
7775
  * @param peerId - The peer ID to remove
7433
7776
  */
7434
7777
  async removePeer(peerId) {
@@ -7478,6 +7821,9 @@ export class CadreNode {
7478
7821
  * tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
7479
7822
  * while alone is re-issued on cohort growth like every other removal.
7480
7823
  *
7824
+ * Removes the `OwnerKey` row only. The call that removes a machine completely, its owner key
7825
+ * included, is {@link removeMachine}.
7826
+ *
7481
7827
  * @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
7482
7828
  * @returns `true` when the row was removed, `false` when the key is not a live owner here.
7483
7829
  */
@@ -7488,6 +7834,155 @@ export class CadreNode {
7488
7834
  const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
7489
7835
  return await this.seedBootstrapService.removeOwner(key);
7490
7836
  }
7837
+ /**
7838
+ * The cadre's machines as an app lists them ({@link CadreMachine}): one entry per live
7839
+ * `CadrePeer` row, judged by this node's membership predicate, plus one per live `OwnerKey`
7840
+ * whose key names a machine with no row (`describeMachines` in `machine-removal.ts`).
7841
+ * Unlocked reads with the default retry.
7842
+ */
7843
+ async listMachines() {
7844
+ if (!this.controlDatabase) {
7845
+ throw new Error('CadreNode must be started before listing machines');
7846
+ }
7847
+ return (await this.readMachines(this.controlDatabase)).machines;
7848
+ }
7849
+ /**
7850
+ * What {@link removeMachine} would do for `peerId`, read without writing, for an app's
7851
+ * confirmation step ({@link MachineRemovalPlan}): whether this node may remove it, whether its
7852
+ * owner key goes with it, which rows that key vouched (so the app can offer to keep them by
7853
+ * re-vouching), which invitations it could come back through, and whether this node's own
7854
+ * rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
7855
+ */
7856
+ async planMachineRemoval(peerId) {
7857
+ if (!this.controlDatabase) {
7858
+ throw new Error('CadreNode must be started before planning a machine removal');
7859
+ }
7860
+ return (await this.planMachineRemovalWith(this.controlDatabase, peerId)).plan;
7861
+ }
7862
+ /**
7863
+ * Remove a machine from the cadre completely, signed by this node's owner key: its
7864
+ * `CadrePeer` row, its `OwnerKey` row when its key is an owner, its `DeviceToken` row (a
7865
+ * removed phone must not be push-woken) and a withdrawal of every live invitation it could
7866
+ * redeem to come back, in ONE transaction ({@link ControlDatabase.removeMachine}), with a
7867
+ * `Revocation` tombstone per row so the removal holds on every machine
7868
+ * ("Deletes made while alone" in docs/architecture.md).
7869
+ *
7870
+ * Steps: re-plan ({@link planMachineRemoval}) and throw {@link MachineRemovalRefusedError} on a
7871
+ * refusal; check `options.revouch` against the plan's dependents and throw before any write
7872
+ * when it names anything else; **re-vouch first**, under this node's key, the dependents the
7873
+ * caller chose to keep (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they are
7874
+ * never untrusted in between, and stop before the removal if one fails (re-vouches already
7875
+ * made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
7876
+ * machine as a dial target, and the refresh the write notifies hangs up its control
7877
+ * connection ({@link hangUpRevokedControlPeers}) before this resolves.
7878
+ *
7879
+ * Not undone by this call: the machine keeps every row and strand block it already synced
7880
+ * and the party's strand member keys (`tickets/blocked/decide-removed-machine-strand-access`).
7881
+ * A fresh invitation re-admits it later under fresh stamps.
7882
+ *
7883
+ * @returns what was written ({@link MachineRemovalResult}); all false when no live row names
7884
+ * the machine here (already removed elsewhere).
7885
+ * @throws {MachineRemovalRefusedError} `'not-an-owner'`, `'self'` or `'last-owner'`.
7886
+ */
7887
+ async removeMachine(peerId, options = {}) {
7888
+ const service = this.seedBootstrapService;
7889
+ if (!service || !this.controlDatabase) {
7890
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
7891
+ }
7892
+ const { plan, rows } = await this.planMachineRemovalWith(this.controlDatabase, peerId);
7893
+ if (plan.refusal !== null) {
7894
+ throw new MachineRemovalRefusedError(peerId, plan.refusal);
7895
+ }
7896
+ const revouch = revouchWithinPlan(plan, options.revouch);
7897
+ if (plan.machine === null) {
7898
+ log('removeMachine(%s): no live row names it here; nothing to remove', peerId);
7899
+ return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [], revouched: { owners: [], peers: [] } };
7900
+ }
7901
+ await this.revouchDependents(service, revouch, rows);
7902
+ const writes = await service.removeMachine({
7903
+ peerId,
7904
+ ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
7905
+ inviteKeys: plan.invitations,
7906
+ });
7907
+ if (writes.removedPeer) {
7908
+ // Track + loudly flag a delete that committed local-only (security-relevant).
7909
+ this.noteControlWrite(peerId, 'remove');
7910
+ }
7911
+ // As removePeer: a removed machine is no longer a dial target, however this node learned
7912
+ // its address. A persist failure only lets the entry reappear after a restart.
7913
+ void this.bootstrapPeerStore?.forget(peerId).catch((error) => {
7914
+ log('removeMachine: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
7915
+ });
7916
+ log('removeMachine(%s): peer=%s ownerKey=%s deviceToken=%s invitations=%d revouched owners=%d peers=%d', peerId, writes.removedPeer, writes.removedOwnerKey, writes.removedDeviceToken, writes.withdrawnInvitations.length, revouch.owners.length, revouch.peers.length);
7917
+ return { ...writes, revouched: revouch };
7918
+ }
7919
+ /**
7920
+ * The reads behind {@link listMachines}: the live `CadrePeer` and `OwnerKey` rows and the
7921
+ * machines projected from them, with this node's membership predicate as the `trusted` test.
7922
+ */
7923
+ async readMachines(controlDatabase) {
7924
+ const [rows, owners] = await Promise.all([controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows()]);
7925
+ const chain = await this.chainFor(rows, this.invitationChainSource(controlDatabase, true));
7926
+ const selfPeerId = this.peerId?.toString() ?? null;
7927
+ const machines = describeMachines({
7928
+ partyId: this.partyId,
7929
+ selfPeerId,
7930
+ rows,
7931
+ owners,
7932
+ trusted: (row) => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain),
7933
+ });
7934
+ return { machines, rows, owners };
7935
+ }
7936
+ /** {@link planMachineRemoval} with the rows it was judged from, which {@link removeMachine} re-vouches dependents by. */
7937
+ async planMachineRemovalWith(controlDatabase, peerId) {
7938
+ const { machines, rows, owners } = await this.readMachines(controlDatabase);
7939
+ const [invites, usages] = await Promise.all([controlDatabase.listCadreInviteStatuses(), controlDatabase.queryCadreInviteUsages()]);
7940
+ const plan = planMachineRemoval({
7941
+ peerId,
7942
+ machines,
7943
+ rows,
7944
+ owners,
7945
+ selfPeerId: this.peerId?.toString() ?? null,
7946
+ selfOwnerKey: this.ownerSigningKeyEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKeyB64 ?? null,
7947
+ invites,
7948
+ usages,
7949
+ });
7950
+ return { plan, rows };
7951
+ }
7952
+ /**
7953
+ * The re-vouch step of {@link removeMachine}: every chosen dependent row is rebound to this
7954
+ * node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
7955
+ * Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
7956
+ * the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
7957
+ * (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
7958
+ *
7959
+ * The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
7960
+ * `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
7961
+ * would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
7962
+ * the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
7963
+ * bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
7964
+ * changes. A row never self-published carries no stamp to keep and gets the clock.
7965
+ *
7966
+ * A failure rethrows and stops the call before the removal; re-vouches already made stay
7967
+ * (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
7968
+ * committed alone is not re-issued on growth (the add half has no re-issue path,
7969
+ * `tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue`), so it is logged as
7970
+ * {@link addOwner} logs it; the peer re-touches are re-issued by the existing drain.
7971
+ */
7972
+ async revouchDependents(service, revouch, rows) {
7973
+ for (const key of revouch.owners) {
7974
+ await service.revouchOwner(key);
7975
+ if (this.committedAlone()) {
7976
+ log('removeMachine: re-vouch of owner %s committed while ALONE (0 control connections): the re-added ' +
7977
+ 'owner row is local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
7978
+ }
7979
+ }
7980
+ for (const dependent of revouch.peers) {
7981
+ const row = rows.find((candidate) => candidate.peerId === dependent);
7982
+ const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
7983
+ await service.reauthorizePeer(dependent, updatedAt);
7984
+ }
7985
+ }
7491
7986
  /**
7492
7987
  * Create a seed from the current control network state.
7493
7988
  * The seed contains peer information and is signed by an owner.
@@ -7715,6 +8210,29 @@ export class CadreNode {
7715
8210
  // ============================================================================
7716
8211
  // Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
7717
8212
  // ============================================================================
8213
+ /**
8214
+ * Where a device could redeem a cadre invitation minted now from, once this machine is gone
8215
+ * ({@link CadreInvitationReach}): the same member selection {@link createCadreInvitation}
8216
+ * makes, judged without minting anything. `rentNode` reads it to choose between inviting a
8217
+ * provider's container and claiming it; `local-network` counts as not reachable there, since
8218
+ * the container is never on a member's LAN. Requires a started node.
8219
+ */
8220
+ async invitationReach() {
8221
+ if (!this.controlNode) {
8222
+ throw new Error('CadreNode must be started before judging invitation reach');
8223
+ }
8224
+ return (await this.selectInvitationMembers()).reach;
8225
+ }
8226
+ /**
8227
+ * The `members` a cadre invitation minted now would name, and how far they reach: one
8228
+ * selection behind both {@link createCadreInvitation} and {@link invitationReach}, so the
8229
+ * two cannot disagree.
8230
+ */
8231
+ async selectInvitationMembers() {
8232
+ const { siblings, connected } = await this.resolveInvitationSiblings();
8233
+ const members = selectCadreInvitationMembers(await this.resolveInviteAddresses(), siblings, connected);
8234
+ return { members, reach: cadreInvitationReach(members, this.controlNode.peerId.toString()) };
8235
+ }
7718
8236
  /**
7719
8237
  * Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
7720
8238
  * this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
@@ -7732,9 +8250,13 @@ export class CadreNode {
7732
8250
  * only, never the raw replicated `OwnerKey` table, because the device anchors whatever
7733
8251
  * arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
7734
8252
  * node's anchor; an empty anchor is refused because a reply could not be checked), and
7735
- * the addresses of this machine first
7736
- * ({@link resolveInviteAddresses}, which honours pushed NAT addresses) then up to three
7737
- * other members ({@link siblingInvitationAddrs}).
8253
+ * at most six addresses: this machine's first ({@link resolveInviteAddresses}, which
8254
+ * honours pushed NAT addresses), then two each of up to three other members
8255
+ * (`selectCadreInvitationMembers`), so the encoded text fits a QR code.
8256
+ *
8257
+ * The result's `reach` says where a device could redeem it from once this machine is gone
8258
+ * (`cadreInvitationReach`, judged on the other members' addresses only), so an app can warn
8259
+ * before handing over an invitation only it could answer.
7738
8260
  *
7739
8261
  * The row commits locally and replicates like any other control write; minted while
7740
8262
  * alone (a phone with no connection), it reaches the other members through the
@@ -7758,7 +8280,7 @@ export class CadreNode {
7758
8280
  if (!Number.isInteger(uses) || uses < 1) {
7759
8281
  throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
7760
8282
  }
7761
- const members = [...await this.resolveInviteAddresses(), ...await this.siblingInvitationAddrs()];
8283
+ const { members, reach } = await this.selectInvitationMembers();
7762
8284
  if (members.length === 0) {
7763
8285
  throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
7764
8286
  }
@@ -7780,8 +8302,8 @@ export class CadreNode {
7780
8302
  ownerKeys,
7781
8303
  members,
7782
8304
  };
7783
- log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es))', row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length);
7784
- return { invitation, encoded: encodeCadreInvitation(invitation) };
8305
+ log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es), reach=%s)', row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length, reach);
8306
+ return { invitation, encoded: encodeCadreInvitation(invitation), reach };
7785
8307
  }
7786
8308
  /**
7787
8309
  * Every cadre invitation this node holds — minted here or replicated in — with its
@@ -7818,7 +8340,7 @@ export class CadreNode {
7818
8340
  * Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
7819
8341
  * anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
7820
8342
  * does for a seed invite, so a later attempt or seed from the same owner is anchored);
7821
- * sign the request; dial the listed members in order ({@link redeemAtMembers}); on
8343
+ * sign the request; dial the listed members in order (`redeemAtMembers`); on
7822
8344
  * acceptance verify the reply against the pinned keys, merge its dial hints into the peer
7823
8345
  * store, retain the answering member as a cold-start dial target, refresh the membership
7824
8346
  * gate and start a cohort reconcile pass. This node then syncs the control database over
@@ -7827,11 +8349,20 @@ export class CadreNode {
7827
8349
  * A device that is already a member (a restart with the invitation still configured) is
7828
8350
  * answered as accepted by the member's idempotent redemption, with no second usage row.
7829
8351
  *
8352
+ * One pass over the listed members by default. With `options.waitForMember` the call keeps
8353
+ * making passes while every answer is retryable — for a device redeeming while no
8354
+ * always-on member is up — until a member accepts, one refuses finally, the invitation
8355
+ * expires (thrown as the `invite-spent` refusal) or `options.signal` aborts
8356
+ * (`redeemInPasses`). The request is signed once, before the first pass, and every pass
8357
+ * sends it: its `usageStampId` keys the usage row, so a member that admitted this node on a
8358
+ * pass whose reply was lost answers the next as already a member. `options.onProgress`
8359
+ * reports each pass and pause.
8360
+ *
7830
8361
  * When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
7831
- * row by consent. The caller still has to wire that key for signing afterwards —
7832
- * `initializeSeedBootstrap(ownKey)`, which anchors it; never `ensureOwnerKey`, the row is
7833
- * already there. Other machines derive this node's key into their anchors from the row's
7834
- * stored proof on their next membership refresh ({@link syncOwnerAnchor}).
8362
+ * row by consent. The caller still has to wire that key for signing afterwards, with
8363
+ * {@link wireOwnKeyIfOwner}; never `ensureOwnerKey`, the row is already there. Other
8364
+ * machines derive this node's key into their anchors from the row's stored proof on their
8365
+ * next membership refresh ({@link syncOwnerAnchor}).
7835
8366
  *
7836
8367
  * Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
7837
8368
  * forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
@@ -7842,10 +8373,10 @@ export class CadreNode {
7842
8373
  *
7843
8374
  * @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
7844
8375
  * an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
7845
- * listed address was tried without an acceptance, and a plain `Error` for the
7846
- * preconditions above.
8376
+ * listed address was tried without an acceptance (one pass only), the signal's reason on
8377
+ * an abort, and a plain `Error` for the preconditions above.
7847
8378
  */
7848
- async redeemCadreInvitation(invitation) {
8379
+ async redeemCadreInvitation(invitation, options = {}) {
7849
8380
  const controlNode = this.controlNode;
7850
8381
  if (!controlNode || !this.controlDatabase || !this._running) {
7851
8382
  throw new Error('CadreNode must be started before redeeming a cadre invitation');
@@ -7860,11 +8391,14 @@ export class CadreNode {
7860
8391
  await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
7861
8392
  const pinned = new Set(invitation.ownerKeys);
7862
8393
  const request = signRedeemRequest(invitation, { peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 }, generateStampId(controlNode.peerId.toString()), await this.resolveInviteAddresses());
7863
- const { reply, memberPeerId, memberAddr } = await redeemAtMembers(controlNode, {
8394
+ const { reply, memberPeerId, memberAddr } = await redeemInPasses(controlNode, {
7864
8395
  invitation,
7865
8396
  request,
7866
8397
  isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
7867
8398
  linkRoundTripMs: this.config.network?.linkRoundTripMs,
8399
+ waitForMember: options.waitForMember ?? false,
8400
+ ...(options.signal ? { signal: options.signal } : {}),
8401
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
7868
8402
  });
7869
8403
  log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
7870
8404
  const selfPeerId = controlNode.peerId.toString();
@@ -7882,6 +8416,38 @@ export class CadreNode {
7882
8416
  void this.reconcileControlCohort().catch((error) => log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
7883
8417
  return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
7884
8418
  }
8419
+ /**
8420
+ * If this node's identity key is a live `OwnerKey` here, wire it for signing
8421
+ * ({@link initializeSeedBootstrap}) and return true; otherwise change nothing and return
8422
+ * false. For a machine that joined someone else's cadre by invitation: after an
8423
+ * owner-granting redemption, and on every later start. A redemption seats the request's
8424
+ * `peerKey`, the key behind this node's peer id, so the identity key is the one to look for.
8425
+ *
8426
+ * Unlike `ensureOwnerKey` it never inserts an `OwnerKey` row, so a machine that joined a
8427
+ * cadre can never genesis-insert itself into it, and a non-owner never anchors its own key.
8428
+ * An owner whose key is already wired (an earlier call) is answered true with nothing redone.
8429
+ *
8430
+ * NOTE: a node running the listener-only seed service ({@link enableSeedListener}) cannot be
8431
+ * wired this way: libp2p takes one seed handler, and {@link initializeSeedBootstrap} would
8432
+ * register a second. Nothing that joins by invitation enables the listener today; if one
8433
+ * does, replace the listener here rather than beside it.
8434
+ *
8435
+ * @throws when the node is not started.
8436
+ */
8437
+ async wireOwnKeyIfOwner() {
8438
+ if (!this.controlDatabase || !this._running) {
8439
+ throw new Error('CadreNode must be started before wiring its owner key');
8440
+ }
8441
+ const signingKey = this.getSelfSigningKey();
8442
+ if (!signingKey || !(await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64)) {
8443
+ log('wireOwnKeyIfOwner: this node\'s key is not an owner here; not wiring it');
8444
+ return false;
8445
+ }
8446
+ if (!this.seedBootstrapService?.canAuthorize()) {
8447
+ await this.initializeSeedBootstrap(signingKey.privateKeyB64);
8448
+ }
8449
+ return true;
8450
+ }
7885
8451
  // ============================================================================
7886
8452
  // Strand Solicitation API (native cadre-core formation transport)
7887
8453
  // ============================================================================
@@ -8010,20 +8576,27 @@ export class CadreNode {
8010
8576
  return await this.strandSolicitationService.createOpenInvitation(sAppId, expirationMs, bootstrap);
8011
8577
  }
8012
8578
  /**
8013
- * Addresses of the party's other machines for an invitation's bootstrap list — an open
8014
- * strand invitation's ({@link createOpenInvitation}) or a cadre invitation's
8015
- * ({@link createCadreInvitation}); {@link selectInvitationSiblingAddrs} picks which. The
8016
- * source is each authorized member's signed `CadrePeer` record ({@link resolvePeerRecord}),
8017
- * so a machine whose record is missing, stale or untrusted is left out; live connections
8018
- * only order them.
8579
+ * Addresses of the party's other machines for an open strand invitation's bootstrap list
8580
+ * ({@link createOpenInvitation}); {@link selectInvitationSiblingAddrs} picks which.
8581
+ */
8582
+ async siblingInvitationAddrs() {
8583
+ const { siblings, connected } = await this.resolveInvitationSiblings();
8584
+ return selectInvitationSiblingAddrs(siblings, connected);
8585
+ }
8586
+ /**
8587
+ * The party's other machines an invitation may name — an open strand invitation
8588
+ * ({@link siblingInvitationAddrs}) or a cadre invitation ({@link createCadreInvitation}) —
8589
+ * and which of them this node is connected to. The source is each authorized member's
8590
+ * signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record is
8591
+ * missing, stale or untrusted is left out; live connections only order them.
8019
8592
  *
8020
8593
  * Best-effort: a failed read leaves the invitation naming this machine alone, which is what
8021
8594
  * it named before siblings were added.
8022
8595
  */
8023
- async siblingInvitationAddrs() {
8596
+ async resolveInvitationSiblings() {
8024
8597
  const controlNode = this.controlNode;
8025
8598
  if (!controlNode || !this.controlDatabase) {
8026
- return [];
8599
+ return { siblings: [], connected: new Set() };
8027
8600
  }
8028
8601
  try {
8029
8602
  const siblings = [];
@@ -8033,12 +8606,11 @@ export class CadreNode {
8033
8606
  siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
8034
8607
  }
8035
8608
  }
8036
- const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
8037
- return selectInvitationSiblingAddrs(siblings, connected);
8609
+ return { siblings, connected: new Set(controlNode.getConnections().map((c) => c.remotePeer.toString())) };
8038
8610
  }
8039
8611
  catch (error) {
8040
8612
  log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
8041
- return [];
8613
+ return { siblings: [], connected: new Set() };
8042
8614
  }
8043
8615
  }
8044
8616
  /**
@@ -8059,7 +8631,7 @@ export class CadreNode {
8059
8631
  await this.initializeStrandSolicitation();
8060
8632
  }
8061
8633
  const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
8062
- await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
8634
+ await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs, 'formStrand');
8063
8635
  // A closed host strand's approval carries the joiner's own membership invitation —
8064
8636
  // persist this party's identity key and stage the invitation for bring-up to redeem.
8065
8637
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -8484,15 +9056,15 @@ export class CadreNode {
8484
9056
  * Nothing else would deliver them: the seed is read only at launch and resume, and the
8485
9057
  * refresh pass skips a peer FRET already holds a record for — the stale one, here.
8486
9058
  */
8487
- async recordFormationStrandAddrs(strandId, strandAddrs) {
9059
+ async recordFormationStrandAddrs(strandId, strandAddrs, caller) {
8488
9060
  const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
8489
9061
  .flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
8490
9062
  if (attributed.length === 0) {
8491
- log('formStrand: responder disclosed no usable strand addrs for %s (%d carried) — cross-party seed unchanged', strandId, strandAddrs.length);
9063
+ log('%s: no usable strand addrs for %s (%d carried) — cross-party seed unchanged', caller, strandId, strandAddrs.length);
8492
9064
  return;
8493
9065
  }
8494
9066
  this.formationStrandAddrs.set(strandId, attributed);
8495
- log('formStrand: kept %d of %d cross-party strand addr(s) for %s', attributed.length, strandAddrs.length, strandId);
9067
+ log('%s: kept %d of %d cross-party strand addr(s) for %s', caller, attributed.length, strandAddrs.length, strandId);
8496
9068
  const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
8497
9069
  if (runningNode) {
8498
9070
  await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);