@serfab/cadre-core 0.9.0 → 0.11.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 (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -2,14 +2,29 @@ import type { Libp2p, PeerId } from '@libp2p/interface';
2
2
  import type { Multiaddr } from '@multiformats/multiaddr';
3
3
  import type { CadreNodeConfig, StrandInstance, StrandConfig, SAppConfig, CadreNodeEvents, ControlNetworkSeed, ApplySeedResult, AddDroneOptions, AddPhoneOptions, DroneInitResult, InviteResult, CadreInvite, OpenInvitation, FormStrandResult, StrandFormationDisclosure, ResolveOpts, SelfRegistrationOutcome, ServiceWakeResult, PushPlatform, DeviceTokenRecord, ResolveDeviceTokenOpts } from './types.js';
4
4
  import { type Ed25519KeyPair } from './ed25519-key.js';
5
+ import { type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
6
+ import { type BootstrapPeerStore } from './bootstrap-peer-store.js';
5
7
  import { type SAppIdLookup } from './strand-watcher.js';
6
8
  import { EnrollmentService } from './enrollment.js';
7
9
  import { ControlDatabase } from './control-database.js';
8
10
  import { SeedBootstrapService } from './seed-bootstrap.js';
9
11
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
10
12
  import { StrandSolicitationService, type StrandSolicitationServiceOptions } from './strand-solicitation.js';
13
+ import { type RelayReservationState, type RelayReservationSupervisorOptions } from './relay-reservation.js';
11
14
  import type { WakeAck } from './types.js';
12
15
  import { type ConnectionPathSummary } from './diagnostics/connection-path.js';
16
+ /**
17
+ * How often a running strand re-resolves its siblings' strand-network addresses
18
+ * and re-merges them into its own libp2p address book
19
+ * ({@link CadreNode.refreshStrandPeerAddrs}). Overridable per node via
20
+ * `network.controlCohort.strandAddrRefreshMs`.
21
+ *
22
+ * Ten minutes sits comfortably inside the peerStore's one-hour address expiry
23
+ * (`MAX_ADDRESS_AGE`, see `peer-addr-book.ts`) with headroom for several missed
24
+ * passes, and far above the 15 s reconcile cadence the refresh rides on, so the
25
+ * strand-addr RPC fan-out stays cheap.
26
+ */
27
+ export declare const STRAND_PEER_ADDR_REFRESH_MS: number;
13
28
  type EventHandler<T> = (data: T) => void;
14
29
  /**
15
30
  * CadreNode is the main entry point for a cadre member.
@@ -31,6 +46,15 @@ export declare class CadreNode implements SAppIdLookup {
31
46
  * launch) reads this resolved field, never `config.privateKey` directly.
32
47
  */
33
48
  private identityKey;
49
+ /**
50
+ * Node-local, NON-replicated trusted-owner anchor (see
51
+ * `trusted-owner-store.ts`). Constructed (or adopted from
52
+ * `config.trustedOwners.store`) by {@link initializeTrustedOwnerStore} during
53
+ * {@link start}; deliberately NOT cleared by {@link cleanup}, so an in-memory
54
+ * anchor survives a stop()→start() cycle of the same node instance. Never
55
+ * sourced from replicated control state.
56
+ */
57
+ private trustedOwnerStore;
34
58
  private controlNode;
35
59
  private controlDatabase;
36
60
  private strandWatcher;
@@ -56,6 +80,14 @@ export declare class CadreNode implements SAppIdLookup {
56
80
  private pushFanoutService;
57
81
  /** Backing field for the {@link running} / {@link isRunning} getters. */
58
82
  private _running;
83
+ /**
84
+ * The control database's own raw storage (cache-wrapped), resolved once per
85
+ * `start()` and released in {@link cleanup}. Owning it is what keeps
86
+ * `config.storage.provider('control')` a once-per-runtime call: a `stop()` then
87
+ * `start()` cycle on this object re-resolves against a live cache, and never
88
+ * orphans the previous wrapper's registration in the shared cache pool.
89
+ */
90
+ private controlStorage;
59
91
  /**
60
92
  * In-flight {@link serviceWake} operations keyed by strandId. Coalesces
61
93
  * concurrent on-demand wakes for the same strand into one runtime build + one
@@ -70,6 +102,25 @@ export declare class CadreNode implements SAppIdLookup {
70
102
  */
71
103
  private windowWaiters;
72
104
  private eventHandlers;
105
+ /**
106
+ * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through.
107
+ * Empty (the default) means nobody asked, so {@link getRelayReservationState}
108
+ * reports `none` — this whole surface is opt-in and dormant for the CLI/host
109
+ * nodes, which use the configured `network.relayAddrs` route instead.
110
+ */
111
+ private relayReserveAddrs;
112
+ /**
113
+ * The running retry loop for {@link relayReserveAddrs}, or `null` when nobody
114
+ * asked for a reservation / there was no control node to supervise. It owns the
115
+ * in-flight flag, the last failure and the next-attempt time; this class only
116
+ * starts it, stops it and reads it.
117
+ */
118
+ private relayReserveSupervisor;
119
+ /**
120
+ * Reason {@link reserveRelays} produced no reservation when there is no
121
+ * supervisor to hold one — i.e. the pre-start `control node unavailable` case.
122
+ */
123
+ private relayReserveError;
73
124
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
74
125
  private sAppConfigs;
75
126
  /**
@@ -79,6 +130,116 @@ export declare class CadreNode implements SAppIdLookup {
79
130
  * control-network node never needs to dial back to the manager.
80
131
  */
81
132
  private latestInviteAddresses;
133
+ /**
134
+ * Epoch ms until which the control-network inbound gate admits
135
+ * not-yet-authorized peers (see {@link admitInboundControlConnection} /
136
+ * {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
137
+ * {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
138
+ * inside an outstanding invite's validity must not strand the invitee.
139
+ *
140
+ * NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
141
+ * the owner re-mints or the invitee is authorized. Fine while invites are
142
+ * short-lived and redeemed promptly; if long-lived invites or restart-prone
143
+ * hosts become normal, persist the window (or derive it from the issued-invite
144
+ * records `SeedBootstrapService` already keeps).
145
+ */
146
+ private enrollmentWindowUntil;
147
+ /**
148
+ * Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
149
+ * {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
150
+ */
151
+ private bootstrapPeerIds;
152
+ /**
153
+ * Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
154
+ * {@link listAuthorizedMembers} result), consulted by the per-stream
155
+ * control-DB gate ({@link authorizeInboundControlStream}). That predicate
156
+ * runs on EVERY inbound Optimystic control-DB stream and must never await a
157
+ * control-DB read: those reads pull blocks over the very protocols the
158
+ * predicate gates, which closes a circular wait that ends in mutual denial
159
+ * (the upstream gate is fail-closed on timeout, unlike the fail-open
160
+ * connection gater). So the set is refreshed OUT OF BAND instead — after
161
+ * start, on every {@link reconcileControlCohort} pass (15s cadence), and
162
+ * immediately after every LOCAL membership mutation so a just-vouched peer
163
+ * is admitted without waiting for the timer. That last refresh is AUTOMATIC:
164
+ * the control database notifies {@link refreshMembershipGate} after every
165
+ * committed `CadrePeer` write (see `ControlDatabase.mutateCadrePeer`), so no
166
+ * writer has to remember. A change that arrives by REPLICATION is picked up
167
+ * on the next timed refresh — bounded staleness, acceptable because this gate
168
+ * is defense in depth: rows an unadmitted peer manages to write are still
169
+ * disbelieved at read time by the voucher-anchored predicate.
170
+ */
171
+ private authorizedControlPeers;
172
+ /**
173
+ * Coalescing state for {@link refreshMembershipGate}: a pending "the snapshot
174
+ * is stale" flag, the single in-flight drain that consumes it, and the depth
175
+ * of open {@link deferMembershipGateRefresh} scopes (a burst of writes inside
176
+ * one scope collapses to a single refresh at scope exit).
177
+ */
178
+ private membershipGateDirty;
179
+ private membershipGateDrain;
180
+ private membershipGateDeferDepth;
181
+ /**
182
+ * In-memory delegate admission grants (see `delegate-admission.ts`): the
183
+ * strand-node transport peerIds this party's members have announced over the
184
+ * strand-addr RPC, admitted at the CONNECTION level so a member's NAT'd
185
+ * strand node can hold a circuit-relay reservation on this node. Consulted
186
+ * ONLY by {@link admitInboundControlConnection}; the fail-closed per-stream
187
+ * gate ({@link authorizeInboundControlStream}) never honors it.
188
+ */
189
+ private readonly delegateAdmission;
190
+ /**
191
+ * When this node last ANNOUNCED a delegate, keyed `<targetPeerId>\n<strandId>`
192
+ * (the peer announced TO, not the delegate). Throttles
193
+ * {@link refreshDelegateGrants} to once per `DELEGATE_GRANT_TTL_MS / 2` per
194
+ * (relay, strand) so the 15 s reconcile tick never becomes per-tick RPC
195
+ * chatter; the launch/resume announce passes record here too. Keys whose
196
+ * strand is no longer running are pruned on each reconcile pass.
197
+ */
198
+ private readonly delegateAnnounceAt;
199
+ /**
200
+ * When each running strand last had its siblings' strand addresses re-resolved
201
+ * into its own address book, keyed by strandId. Throttles
202
+ * {@link refreshStrandPeerAddrs} to once per {@link STRAND_PEER_ADDR_REFRESH_MS}
203
+ * per strand so the 15 s reconcile tick never becomes per-tick RPC chatter.
204
+ * Entries for strands that are no longer running are pruned on each pass, so a
205
+ * hibernated-then-resumed strand refreshes immediately rather than inheriting a
206
+ * stale stamp.
207
+ */
208
+ private readonly strandPeerAddrRefreshAt;
209
+ /**
210
+ * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
211
+ * this node has applied, keyed by peer id, valued by the seed's multiaddr
212
+ * strings (parsed lazily, at dial time). Written by
213
+ * {@link recordSeedBootstrapPeers}; read only by
214
+ * {@link dialColdStartBootstrap} while the control database still has no
215
+ * siblings to dial.
216
+ *
217
+ * A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
218
+ * targets must outlive the process: a node seeded into a party it could not
219
+ * reach has nothing else on disk naming that party's addresses (`applySeed`
220
+ * writes no control row, and `CadrePeer` fills in only after a connection
221
+ * succeeds), so an in-memory-only set left it stranded permanently across a
222
+ * restart. Constructed (or adopted from `config.bootstrapPeers.store`) by
223
+ * {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
224
+ * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
225
+ * node instance — same lifecycle as {@link trustedOwnerStore}. Durability
226
+ * depends on the injected backend: Node gets a file-backed store from the CLI,
227
+ * RN/browser stay ephemeral for now.
228
+ *
229
+ * Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
230
+ * reads for the steady-state path. The peer store is shared with everything
231
+ * libp2p discovers, so "dial every entry" would grow into dialing arbitrary
232
+ * discovered peers as the node lives longer; this store holds exactly the peers
233
+ * an owner-signed, trust-anchored seed nominated as owners. `applySeed` merges
234
+ * the same addresses into the peer store as well, so the two never disagree —
235
+ * this one is just the precisely-scoped subset.
236
+ *
237
+ * A later seed OVERWRITES an entry rather than merging, so a re-seed after an
238
+ * owner's address changes replaces the stale address instead of accumulating
239
+ * dead ones. Entries are never evicted: they are the node's only way back into
240
+ * the party if it is ever stranded again.
241
+ */
242
+ private bootstrapPeerStore;
82
243
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
83
244
  private selfRegistrationTimer;
84
245
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -107,15 +268,38 @@ export declare class CadreNode implements SAppIdLookup {
107
268
  */
108
269
  private registerSelfInFlight;
109
270
  /**
110
- * Re-replication queue for owner control writes that committed while this
111
- * node was alone — `controlNode.getConnections().length === 0` at write time
112
- * means the Optimystic commit was local-only (the block's cluster was ≤1) and
113
- * never broadcast. Maps the affected subject peerId → the write to re-issue once
114
- * the cohort grows. A later op for the same subject overwrites an earlier one
115
- * (authorize then remove → remove wins). Drained on the 0→≥1 control-connection
116
- * transition by {@link drainPendingControlReplication}.
271
+ * Re-replication queue for owner `authorizePeer` writes that committed while
272
+ * this node was alone — `controlNode.getConnections().length === 0` at write
273
+ * time means the Optimystic commit was local-only (the block's cluster was ≤1)
274
+ * and never broadcast. Maps the affected subject peerId → 'authorize', to
275
+ * re-issue once the cohort grows. Removals are NOT tracked here — a delete
276
+ * leaves a `Revocation` tombstone, tracked by {@link pendingRevocations}; a
277
+ * removePeer also clears any queued authorize for the same subject (the row is
278
+ * gone, so re-issuing the insert would be wrong). Drained on the 0→≥1
279
+ * control-connection transition by {@link drainPendingControlReplication}.
117
280
  */
118
281
  private pendingPeerWrites;
282
+ /**
283
+ * Re-replication queue for `Revocation` tombstones that committed while this
284
+ * node was alone, keyed on the retired `StampId`. Fed by the control DB's
285
+ * committed-delete seam ({@link noteGuardedDelete}), so one queue covers all
286
+ * four guarded tables (`CadrePeer` / `DeviceToken` / `Strand` /
287
+ * `ValidationKey`). Drained (owner-signed `ReissuedAt` bump, which re-broadcasts
288
+ * the tombstone) by {@link drainPendingRevocations}. Cleared on stop alongside
289
+ * {@link pendingPeerWrites} — the tombstone rows are durable, so the next
290
+ * lifetime's first-growth sweep re-covers anything dropped here.
291
+ */
292
+ private pendingRevocations;
293
+ /**
294
+ * Guards the once-per-lifetime first-growth revocation sweep (re-issue EVERY
295
+ * locally-held tombstone, covering removals from before this node started).
296
+ * Deliberately separate from {@link reconstructedLocalOnlyWrites} and set only
297
+ * after a sweep pass SUCCEEDS (including the nothing-held case) — a throwing
298
+ * sweep retries on the next growth edge instead of being lost for the lifetime.
299
+ * Reset on stop, so a stop()→start() cycle sweeps again (that second start is
300
+ * exactly a "removals from before this lifetime" case).
301
+ */
302
+ private reissuedHeldRevocations;
119
303
  /** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
120
304
  private pendingSelfPeerWrite;
121
305
  /** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
@@ -249,11 +433,11 @@ export declare class CadreNode implements SAppIdLookup {
249
433
  * before any network bring-up. Resolution order:
250
434
  *
251
435
  * 1. Both `keyStore` and `privateKey` set ⇒ configuration error (throws).
252
- * 2. `keyStore` set ⇒ load protobuf bytes from `identityKeyId` (default
253
- * {@link DEFAULT_IDENTITY_KEY_ID}). Found ⇒ deserialize; empty ⇒ generate a
254
- * fresh Ed25519 key, persist it, and use it. A rejected `get` (access denied
255
- * / backend failure) PROPAGATES — we never generate a new key on a read
256
- * error, which would silently orphan the real identity.
436
+ * 2. `keyStore` set ⇒ {@link loadOrCreateIdentityKey} against `identityKeyId`
437
+ * (default {@link DEFAULT_IDENTITY_KEY_ID}): load when the slot is present,
438
+ * generate + persist when it is empty. A rejected `get` (access denied /
439
+ * backend failure) PROPAGATES — we never generate a new key on a read error,
440
+ * which would silently orphan the real identity.
257
441
  * 3. `privateKey` set ⇒ use it directly.
258
442
  * 4. Neither ⇒ leave undefined; libp2p generates an ephemeral key.
259
443
  *
@@ -261,7 +445,246 @@ export declare class CadreNode implements SAppIdLookup {
261
445
  * resolved key rather than regenerating or re-persisting.
262
446
  */
263
447
  private resolveIdentityKey;
448
+ /**
449
+ * Construct (or adopt) the node-local trusted-owner anchor and seed the
450
+ * out-of-band pinned keys from `config.trustedOwners`. The store is NEVER
451
+ * sourced from the replicated control DB — its entries come only from
452
+ * genesis self-trust ({@link initializeSeedBootstrap}), config pins (here),
453
+ * or runtime enrollment pins ({@link trustOwnerKeys}).
454
+ *
455
+ * Idempotent across stop()→start(): the store instance is kept, and
456
+ * re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
457
+ * idempotent). An injected store scoped to a different party is a
458
+ * configuration error (fail closed before any network bring-up).
459
+ */
460
+ private initializeTrustedOwnerStore;
461
+ /**
462
+ * Construct (or adopt) the node-local cold-start bootstrap-peer store (see
463
+ * {@link bootstrapPeerStore}). Synchronous: an injected store has already
464
+ * loaded its persisted targets by the time it is handed in (its `open` is the
465
+ * async part), and the in-memory fallback has nothing to load.
466
+ *
467
+ * Idempotent across stop()→start(): the store instance is kept, so retained
468
+ * targets survive a restart of the same node instance even with the ephemeral
469
+ * default. An injected store scoped to a different party is a configuration
470
+ * error (fail closed before any network bring-up) — a foreign party's addresses
471
+ * must never enter this node's dial loop.
472
+ */
473
+ private initializeBootstrapPeerStore;
474
+ /**
475
+ * The node-local cold-start bootstrap-peer store (null before {@link start}) —
476
+ * the retained owner addresses {@link reconcileControlCohort}'s cold-start
477
+ * branch re-dials. Exposed for diagnostics and for a host that wants to show
478
+ * "what would this node dial if it is stranded?".
479
+ */
480
+ getBootstrapPeerStore(): BootstrapPeerStore | null;
481
+ /**
482
+ * The node-local trusted-owner anchor (null before {@link start}). This is
483
+ * the set the authorized-membership predicate and seed-trust anchor consult —
484
+ * never the replicated `OwnerKey` table, which any stranger can pollute.
485
+ */
486
+ getTrustedOwnerStore(): TrustedOwnerStore | null;
487
+ /**
488
+ * Persist out-of-band-established owner keys into the node-local anchor —
489
+ * the runtime enrollment seam: call with `CadreInvite.ownerKeys` when
490
+ * redeeming an invite (BEFORE the first `applySeed`, so the anchor already
491
+ * holds the pins when seed trust consults it), or with an operator-supplied
492
+ * pin. Idempotent. ('genesis' provenance is reserved for the node's own
493
+ * founding key, seeded internally by {@link initializeSeedBootstrap}.)
494
+ *
495
+ * Validates every key's shape before trusting any of them (all-or-nothing):
496
+ * a malformed entry anywhere in `keys` rejects the whole call before a
497
+ * single key is anchored. For the invite route this means a `CadreInvite`
498
+ * carrying one malformed `ownerKeys` entry fails the redemption outright —
499
+ * consistent with the anchor's existing whole-record-or-nothing policy for
500
+ * a corrupt persisted entry (see `trusted-owner-store.ts`'s
501
+ * `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
502
+ * and leaving the caller to notice a key went missing.
503
+ */
504
+ trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void>;
264
505
  private createControlNode;
506
+ /**
507
+ * The control database's cache-wrapped raw storage, resolved from
508
+ * `config.storage.provider` on first use and then held in {@link controlStorage}
509
+ * for the rest of this runtime.
510
+ *
511
+ * Resolving once is the contract `RawStorageProvider` (types.ts) states: a provider is
512
+ * called once per scope per runtime lifetime. Re-entering it per call would mint a
513
+ * second store over one backend — a fresh, cold cache, with the previous wrapper's
514
+ * registration orphaned in the process-wide pool.
515
+ *
516
+ * Lazy rather than eager in `start()` so the pure-unit call path
517
+ * (`cadre-node-control-node-options.spec.ts` calls `buildControlNodeOptions` on a
518
+ * bare `new CadreNode`) still resolves — and, on that path too, only once.
519
+ *
520
+ * Wrapped in the write-through raw-storage cache (quereus-plugin-sereus's
521
+ * `cached-storage.ts`) because a control start's cost is its raw-storage
522
+ * operation count.
523
+ */
524
+ private resolveControlStorage;
525
+ /**
526
+ * Map this node's config onto the control network's libp2p node options.
527
+ *
528
+ * Split out of {@link createControlNode} purely so the mapping is assertable without
529
+ * standing up a real libp2p node — `packages/cadre-core/test/cadre-node-control-node-options.spec.ts`
530
+ * calls it on a bare `new CadreNode(config)`. Read nothing else into the split; the
531
+ * only caller in production is `createControlNode`.
532
+ */
533
+ private buildControlNodeOptions;
534
+ /**
535
+ * Decide whether an inbound CONTROL-network connection from `remotePeerId`
536
+ * should be admitted — the policy behind the connection gater wired in
537
+ * {@link createControlNode}. Deny only on a positive "unauthorized outsider
538
+ * while no stranger path is open" determination; everything ambiguous admits
539
+ * and defers to the fail-closed per-stream gates (see
540
+ * `membership-connection-gater.ts` for the layer's rationale and the
541
+ * stranger-open protocol allowlist).
542
+ *
543
+ * Admits when ANY of:
544
+ * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
545
+ * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
546
+ * configured bootstrap/relay infrastructure);
547
+ * 2. an enrollment window is open ({@link openEnrollmentWindow}, opened by
548
+ * {@link createInvite}) — the invitee dials in before it is authorized;
549
+ * 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
550
+ * announced it (over the strand-addr RPC) as the transport peerId of its
551
+ * own strand node, so a NAT'd member's strand node can hold a
552
+ * circuit-relay reservation here (see `delegate-admission.ts`).
553
+ * Connection only: the per-stream gate below never honors a grant;
554
+ * 4. the authorized-member set is empty — cold start: the rows that would
555
+ * authorize anyone arrive by replication over these very connections;
556
+ * 5. the peer IS an authorized member; or
557
+ * 6. an open invitation is OUTSTANDING — at least one unexpired,
558
+ * not-fully-consumed invitation this node minted or persisted (see
559
+ * `StrandSolicitationService.hasOutstandingInvitation`). A formation
560
+ * initiator is another party's peer by design and its token is only
561
+ * checkable inside the protocol, so the gate asks the coarser question
562
+ * "does this node expect a stranger at all?". Merely REGISTERING the
563
+ * responder ({@link initializeStrandSolicitation}) no longer suspends
564
+ * stranger denial — eager registration (as `reference-app-rn` does at
565
+ * bring-up) and `formStrand`'s lazy initialization both leave the gate
566
+ * armed, because neither mints an invitation.
567
+ *
568
+ * Ordering is semantically free (the checks are OR'd) but decides who pays:
569
+ * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
570
+ * already on the deny path reaches check 6's invitation lookup.
571
+ *
572
+ * Caveats of check 6, both self-healing:
573
+ * - the in-memory mint registry dies with the process, so after a restart
574
+ * only invitations persisted as `FormationInvite` rows still hold the
575
+ * exemption open (re-mint otherwise — same story as the enrollment window);
576
+ * - a peer holding a token whose `FormationInvite` row has not replicated to
577
+ * this node yet is denied even though the formation handler would have
578
+ * accepted it, exactly like the unreplicated-membership-row case below.
579
+ *
580
+ * NOTE: check 4/5 runs a control-DB read per inbound connection
581
+ * (`listAuthorizedMembers`); connections are rare and cadres small, and this
582
+ * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
583
+ * read is safe here — unlike the per-stream gate, which must consult the
584
+ * materialized {@link authorizedControlPeers} snapshot instead.
585
+ * NOTE: a sibling whose membership row has not yet replicated to
586
+ * this node is denied until the row converges (typically via the owner);
587
+ * either side's next outbound reconcile dial (outbound is never gated)
588
+ * re-establishes the link — self-healing, but visible as a transient deny.
589
+ */
590
+ private admitInboundControlConnection;
591
+ /**
592
+ * Hold the control-network inbound gate open for not-yet-authorized peers
593
+ * until `untilEpochMs` (extends, never shrinks, an already-open window).
594
+ * {@link createInvite} calls this automatically; a host running an
595
+ * out-of-band enrollment flow (e.g. accepting a phone whose invite this node
596
+ * never minted) can open it explicitly before the stranger dials in.
597
+ */
598
+ openEnrollmentWindow(untilEpochMs: number): void;
599
+ /**
600
+ * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
601
+ * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
602
+ * at the CONNECTION level (all a circuit-relay reservation needs) for
603
+ * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
604
+ * authorized-membership gate has passed; public so tests can drive the
605
+ * admission policy without a full strand launch. A re-announce for the same
606
+ * (announcer, strand) REPLACES the previous delegate rather than
607
+ * accumulating. Never honored by {@link authorizeInboundControlStream}.
608
+ */
609
+ grantDelegateAdmission(announcerPeerId: string, strandId: string, delegatePeerId: string): void;
610
+ /** Is `remotePeerId` covered by a live delegate admission grant? */
611
+ hasDelegateAdmission(remotePeerId: string): boolean;
612
+ /**
613
+ * The admission checks SHARED by the connection gate
614
+ * ({@link admitInboundControlConnection}) and the per-stream control-DB gate
615
+ * ({@link authorizeInboundControlStream}) — factored so the two layers cannot
616
+ * drift. All in-memory and cheap (the stream gate runs this per inbound
617
+ * stream). Returns true when the peer must be admitted BEFORE any membership
618
+ * source is consulted:
619
+ * - the node is not fully up (`start()` in progress / DB torn down) — both
620
+ * gates exist from libp2p bring-up, before the control DB does;
621
+ * - the trusted-owner anchor is absent or empty — an un-enrolled node has
622
+ * no basis to judge anyone and MUST accept its enrollment seed;
623
+ * - the peer is one of the configured control bootstrap/relay nodes —
624
+ * operator-configured infrastructure, not cadre members.
625
+ * False only means "no unconditional admit": the caller then judges the peer
626
+ * against its own membership source (a live DB read for the fail-open
627
+ * connection gate; the materialized snapshot for the fail-closed stream gate).
628
+ */
629
+ private admitControlPeerUnconditionally;
630
+ /**
631
+ * Per-stream authorization for the four Optimystic control-DB protocols
632
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`),
633
+ * wired as `authorizeInboundStream` in {@link createControlNode} — the
634
+ * fail-closed layer behind the fail-open connection gater. Control node
635
+ * ONLY: strand cohort nodes legitimately serve cross-party peers.
636
+ *
637
+ * The STRICT SUBSET of {@link admitInboundControlConnection}: the same
638
+ * "no basis to judge" admissions (shared via
639
+ * {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
640
+ * (enrollment window, outstanding open invitation, delegate admission
641
+ * grant). The first two exist so a stranger can reach `/sereus/seed/1.0.0`
642
+ * and `/sereus/formation/1.0.0` — neither is gated here, and admitting a
643
+ * stranger to `repo` during an enrollment window is exactly the hole this
644
+ * gate closes. A DELEGATE-admitted connection (a member's strand node
645
+ * holding a circuit-relay reservation, see `delegate-admission.ts`) is
646
+ * likewise exactly a case this gate must still refuse: the delegate gets
647
+ * the connection and never the control DB.
648
+ *
649
+ * Admits when ANY of:
650
+ * 1. a shared-baseline check admits (not running / no DB, empty anchor,
651
+ * configured bootstrap infra);
652
+ * 2. the materialized authorized set is empty — cold start, before the
653
+ * rows that would authorize anyone have replicated in;
654
+ * 3. the peer IS in the materialized authorized set.
655
+ *
656
+ * Deliberately SYNCHRONOUS and pure in-memory — see
657
+ * {@link authorizedControlPeers} for why a control-DB read here would
658
+ * deadlock into mutual denial. With a sync predicate the upstream deadline
659
+ * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
660
+ * default.
661
+ *
662
+ * NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
663
+ * key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
664
+ * siblings deny its control-DB streams once their own snapshot is non-empty —
665
+ * owner status is a KEY, not a peer id, so being the owner does not exempt it.
666
+ * Harmless today (a real deployment persists its identity; the ephemeral-owner
667
+ * case appears only in tests, which configure the owner as bootstrap infra —
668
+ * `push-wake-e2e.integration.ts` design note 3). If an ephemeral-identity node
669
+ * ever becomes a supported deployment, this gate needs a key-based admission.
670
+ */
671
+ private authorizeInboundControlStream;
672
+ /**
673
+ * Refresh {@link authorizedControlPeers} from the control DB, best-effort: a
674
+ * failed read keeps the previous snapshot (never clears it), so a transient
675
+ * DB error can neither flip the stream gate's cold-start carve-out back open
676
+ * nor drop a legitimate member mid-flight. Never rejects.
677
+ *
678
+ * The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
679
+ * so two reads can no longer settle out of order.
680
+ */
681
+ private refreshAuthorizedControlPeers;
682
+ /**
683
+ * PeerIds of the configured control bootstrap nodes (relay/bootstrap
684
+ * infrastructure — always admitted inbound). Parsed once, lazily; an
685
+ * address without a `/p2p/<id>` component contributes nothing.
686
+ */
687
+ private getBootstrapPeerIds;
265
688
  private createStrandQueryable;
266
689
  /**
267
690
  * Schedule the node's initial self-record publish + ongoing refresh shortly
@@ -280,7 +703,11 @@ export declare class CadreNode implements SAppIdLookup {
280
703
  * PeerId (the resolved node identity from `keyStore`/`config.privateKey`).
281
704
  * - If the row already exists: a self-signed UPDATE bumping `UpdatedAt`.
282
705
  * - If not, and the node is its own owner: an owner-signed INSERT that
283
- * also carries the self-signature.
706
+ * also carries the self-signature. That INSERT is idempotent, so if an owner
707
+ * {@link authorizePeer} of this node's own id seated the row inside the
708
+ * read-then-insert window it no-ops — the publish then falls through to the
709
+ * UPDATE path (re-reading and re-signing against the row that landed) rather
710
+ * than leaving the authorize's null `Sig` in place, and reports `refreshed`.
284
711
  * - Otherwise: logs and returns (a non-owner node with no row yet must
285
712
  * wait for an owner to insert it; it can then self-refresh).
286
713
  *
@@ -294,6 +721,13 @@ export declare class CadreNode implements SAppIdLookup {
294
721
  registerSelf(): Promise<SelfRegistrationOutcome>;
295
722
  /** The body of {@link registerSelf}; serialised by its single-flight guard. */
296
723
  private publishSelfRecord;
724
+ /**
725
+ * Sign this node's address record for publication, stamped strictly later than
726
+ * `existing` (the row it is about to replace, or null for a first insert) so a
727
+ * same-millisecond re-publish still satisfies the monotonic `UpdatedAt` rule the
728
+ * `CadrePeer.AuthorizedUpdate` self-branch enforces.
729
+ */
730
+ private signSelfRecord;
297
731
  /**
298
732
  * The ed25519 keypair (base64url) the node signs its own record with — the key
299
733
  * behind its libp2p PeerId. Sourced from the resolved {@link identityKey}
@@ -374,23 +808,67 @@ export declare class CadreNode implements SAppIdLookup {
374
808
  /** Body of {@link reconcileControlCohort}; serialised by its single-flight guard. */
375
809
  private runReconcileControlCohort;
376
810
  /**
377
- * Resolve one sibling's control-network dial addresses and dial it, best-effort.
378
- * Returns whether a dial was attempted (false when no address resolves).
811
+ * Resolve every sibling's signed address record once and merge what resolves
812
+ * into the control node's libp2p **address book** (peerStore). Returns the
813
+ * resolved addresses per sibling so the dial loop reuses them rather than
814
+ * re-resolving — one `queryPeerRecord` per sibling per pass, not two.
815
+ *
816
+ * Deliberately every sibling, not the {@link selectControlCohortDials} subset,
817
+ * and deliberately including already-connected ones: everything below
818
+ * cadre-core dials by bare peer id (Optimystic's cluster/repo clients, FRET
819
+ * ping/announce), so the address book has to be warm BEFORE a live connection
820
+ * drops, and a sibling the out-degree cap declines to dial is still one those
821
+ * layers may need to reach.
822
+ *
823
+ * Only `resolvePeerAddrs` output is merged — never the cold-start
824
+ * `peerStoreAddrs` fallback, which came out of the address book to begin with
825
+ * and would restamp unverified seed addresses indefinitely. A sibling that
826
+ * resolves to nothing (revoked, stale, untrusted) is not written at all, so its
827
+ * existing entry ages out on its own.
828
+ *
829
+ * NOTE: this resolves EVERY sibling serially before the dial loop below runs,
830
+ * so it costs one record query per sibling per reconcile pass (~15s) and each
831
+ * one delays the pass's first dial. A cadre is a handful of devices, so today
832
+ * that is a few extra local reads; if cadres ever grow large, batch the records
833
+ * into one query, merge only on change, or move the warm pass after the dials.
834
+ */
835
+ private warmSiblingAddrBook;
836
+ /**
837
+ * {@link resolvePeerAddrs} for one sibling, best-effort: a control-DB read
838
+ * failure yields `[]` (and the cold-start fallback then gets its turn) rather
839
+ * than aborting the whole pass, like every other step here.
840
+ */
841
+ private resolveSiblingAddrs;
842
+ /**
843
+ * Budget for ONE proactive dial of the reconcile pass — steady-state sibling
844
+ * ({@link dialControlSibling}) and cold-start bootstrap peer
845
+ * ({@link dialBootstrapPeer}) alike, since both feed a whole address list to
846
+ * one `dial()` and both run in a sequential loop that the next entry waits on.
379
847
  *
380
- * A per-peer failure (NAT, offline, relay down, connection-gater denial) is
381
- * logged and swallowed so one unreachable sibling never aborts the pass —
382
- * exactly like {@link SeedBootstrapService.applySeed}'s owner-dial loop. A
383
- * failed dial is simply retried on the next pass.
848
+ * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why the pass bounds
849
+ * this itself rather than inheriting each address's libp2p attempt timeout.
850
+ */
851
+ private controlDialTimeoutMs;
852
+ /**
853
+ * Dial one sibling from its already-resolved addresses, best-effort. Returns
854
+ * whether a dial was attempted (false when no address resolves).
855
+ *
856
+ * A per-peer failure (NAT, offline, relay down, connection-gater denial, or
857
+ * the {@link controlDialTimeoutMs} budget expiring) is logged and swallowed so
858
+ * one unreachable sibling never aborts the pass — exactly like
859
+ * {@link SeedBootstrapService.applySeed}'s owner-dial loop. A failed dial is
860
+ * simply retried on the next pass.
384
861
  */
385
862
  private dialControlSibling;
386
863
  /**
387
- * Resolve a sibling's control-network dial addresses for the reconcile pass.
864
+ * A sibling's control-network dial addresses for the reconcile pass.
388
865
  *
389
- * Primary (steady state): the signed, fresh, trust-gated control addresses from
390
- * the converged `CadrePeer` record via {@link resolvePeerAddrs}. Fallback (cold
391
- * start): the libp2p peerStore entries `applySeed` populated, used only while the
392
- * record is not yet resolvable. Returns `[]` (never throws) when neither yields
393
- * an address — that sibling is skipped this pass.
866
+ * Primary (steady state): the signed, fresh, trust-gated control addresses
867
+ * `resolved` for it by {@link warmSiblingAddrBook} — passed in rather than
868
+ * re-resolved, so the pass makes one record query per sibling. Fallback (cold
869
+ * start): the libp2p peerStore entries `applySeed` populated, used only while
870
+ * the record is not yet resolvable. Returns `[]` (never throws) when neither
871
+ * yields an address — that sibling is skipped this pass.
394
872
  */
395
873
  private resolveControlDialAddrs;
396
874
  /**
@@ -399,6 +877,65 @@ export declare class CadreNode implements SAppIdLookup {
399
877
  * parse/lookup failure — never throws.
400
878
  */
401
879
  private peerStoreAddrs;
880
+ /**
881
+ * Retain a just-applied seed's owner-flagged peers as cold-start bootstrap
882
+ * dial targets (see {@link bootstrapPeerStore}).
883
+ *
884
+ * Called for every seed this node accepts, on BOTH intake paths — the
885
+ * {@link applySeed} wrapper (which may run on a throwaway service, so the
886
+ * service itself is the wrong place to keep this) and the inbound
887
+ * `/sereus/seed/1.0.0` handler via `onSeedApplied`. Peers with no address are
888
+ * skipped: there is nothing to dial.
889
+ *
890
+ * `isOwner` is the seed's own claim about its peers, exactly as
891
+ * `SeedBootstrapService.applySeed` uses it to choose its dial targets. That is
892
+ * sound here for the same reason it is sound there — the whole seed is
893
+ * signature-checked against a trust-anchored signer before this runs, and the
894
+ * flag only *selects a dial target*; a dial grants no authority.
895
+ *
896
+ * Self is excluded. `createSeed` projects EVERY `CadrePeer` row, so an owner
897
+ * that applies a seed minted after it joined finds itself in the owner list;
898
+ * retaining that would make the cold-start pass dial this node forever (the
899
+ * steady-state pass filters self out of its sibling list for the same reason).
900
+ */
901
+ private recordSeedBootstrapPeers;
902
+ /**
903
+ * Cold-start branch of the reconcile pass: re-dial the seed's owner peers
904
+ * while the control database still has no siblings to dial.
905
+ *
906
+ * `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
907
+ * When that single dial fails — owner momentarily down, relay reservation not
908
+ * up yet, NAT traversal lost the race — the joining node has an empty
909
+ * `CadrePeer` table and no connection, and the steady-state pass above cannot
910
+ * help: it dials only siblings enumerated from that very table. Retrying here
911
+ * is the only way back in.
912
+ *
913
+ * Unbounded, at the reconcile cadence, with no backoff: the branch is already
914
+ * gated by "the control database has no siblings", so it stops the moment the
915
+ * node is actually in the party, and a node that is stranded MUST keep trying —
916
+ * a give-up rule would turn a transient outage into a permanent one. The steady
917
+ * cost is one dial per bootstrap peer per pass (seeds nominate one or a few
918
+ * owners), each failing fast against an unreachable address.
919
+ * NOTE: if seeds ever carry many owner peers, or the reconcile cadence tightens
920
+ * well below its 15 s default, add per-peer backoff here.
921
+ *
922
+ * Best-effort per peer, exactly like {@link dialControlSibling}: one dead
923
+ * address never aborts the pass.
924
+ */
925
+ private dialColdStartBootstrap;
926
+ /**
927
+ * Bind a bootstrap peer's seed addresses to its peer id, so the dial
928
+ * authenticates the peer it is aiming at rather than trusting whoever answers.
929
+ *
930
+ * An address that already carries `/p2p/<id>` must carry THIS peer's id or it
931
+ * is dropped (a seed that disagrees with itself is not a dial target); one
932
+ * that carries none — an `addressOverrides`-published record, say — gets the
933
+ * id encapsulated. Unencapsulatable addresses are dropped rather than dialed
934
+ * bare.
935
+ */
936
+ private bootstrapDialAddrs;
937
+ /** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
938
+ private dialBootstrapPeer;
402
939
  /**
403
940
  * Whether a control write happening now would commit local-only. A sound lower
404
941
  * bound is "no connected control peers": 0 connections ⇒ the block's cluster is
@@ -408,6 +945,9 @@ export declare class CadreNode implements SAppIdLookup {
408
945
  * is caught by the cohort's periodic pull-on-read instead. Re-issuing an already-
409
946
  * replicated row is harmless (an idempotent monotonic bump), so the coarse proxy
410
947
  * is acceptable as the agreed first cut.
948
+ *
949
+ * Reads {@link getControlConnectionCount} — the public, embedder-facing form of
950
+ * the same sample — so the two cannot drift.
411
951
  */
412
952
  private committedAlone;
413
953
  /**
@@ -417,12 +957,23 @@ export declare class CadreNode implements SAppIdLookup {
417
957
  * cohort grows; otherwise the write replicated and any stale queue entry for this
418
958
  * subject is dropped.
419
959
  *
420
- * A `remove` that commits alone is a security-relevant durability gap (a revoked
421
- * peer may persist as a member elsewhere), so it is logged loudly — and, because
422
- * a physical delete leaves no local trace to re-issue from, its on-growth
423
- * re-issue is best-effort only (see {@link drainPendingPeerWrites}).
960
+ * A `remove` never queues here — a delete's re-replicable half is its
961
+ * `Revocation` tombstone, which the committed-delete seam routes into
962
+ * {@link pendingRevocations} ({@link noteGuardedDelete}). The `remove` arm only
963
+ * drops any queued authorize for the subject (the row is gone; re-issuing the
964
+ * insert would resurrect it) and, when the commit was alone, logs the
965
+ * security-relevant window loudly.
424
966
  */
425
967
  private noteControlWrite;
968
+ /**
969
+ * Committed-delete seam handler ({@link GuardedDeleteListener}, wired in
970
+ * {@link start}): a guarded-table delete and its `Revocation` tombstone
971
+ * committed. If the node was alone the tombstone is local-only — queue it for
972
+ * an owner-signed re-issue on cohort growth; otherwise it broadcast, so drop
973
+ * any stale queue entry for the stamp. Synchronous by contract — bookkeeping
974
+ * only, never throws into the delete path.
975
+ */
976
+ private noteGuardedDelete;
426
977
  /**
427
978
  * Drain the write-while-alone re-replication queue: re-issue the writes that
428
979
  * committed local-only now that the cohort can broadcast them. Public so the
@@ -443,6 +994,38 @@ export declare class CadreNode implements SAppIdLookup {
443
994
  * the drain.
444
995
  */
445
996
  private retouchSelfDeviceToken;
997
+ /**
998
+ * Re-issue `Revocation` tombstones so a removal that committed while alone
999
+ * still reaches the cohort. Two regimes, one method:
1000
+ *
1001
+ * - **First successful pass per process** ({@link reissuedHeldRevocations}
1002
+ * false): sweep EVERY locally-held tombstone from `queryRevocations()` — the
1003
+ * only cover for a removal made before this process started, since the
1004
+ * in-memory queue does not survive a restart. Queued stamps are part of that
1005
+ * same batch (exactly once — one transaction, no double bump).
1006
+ * - **Later passes**: only the tombstones queued in-session by
1007
+ * {@link noteGuardedDelete}.
1008
+ *
1009
+ * Owner-gated: a node with no owner key cannot sign a re-issue, so stray queue
1010
+ * entries are dropped (mirrors {@link drainPendingPeerWrites}). Best-effort — a
1011
+ * failure is logged and leaves the queue (and the sweep flag) untouched for the
1012
+ * next growth edge. A successful `reissueRevocations` exec is NOT proof of
1013
+ * broadcast (the connection that fired the growth edge may not be in the
1014
+ * affected block's cluster — see
1015
+ * tickets/backlog/control-rereplication-broadcast-confirmation); the drain
1016
+ * inherits that known gap, and a full disconnect→reconnect (or the next
1017
+ * process's sweep) re-covers it.
1018
+ *
1019
+ * NOTE: the sweep is O(all tombstones ever) row-updates — plus one owner
1020
+ * signature each — in one transaction, once per lifetime. `Revocation` is
1021
+ * append-only and unbounded growth is declared acceptable for a cadre-sized
1022
+ * party. If the tombstone table ever gets large, bound the sweep (e.g. persist
1023
+ * a node-local high-water mark of what has been re-issued while connected)
1024
+ * instead of re-touching everything; note the `Math.max(...)` spread below also
1025
+ * caps out around 10^5 rows (V8 argument limit) before the size becomes merely
1026
+ * a latency problem.
1027
+ */
1028
+ private drainPendingRevocations;
446
1029
  /**
447
1030
  * One-shot, first-cohort-growth reconstruction of the write-while-alone queue for
448
1031
  * an OWNER node: re-touch every membership row that may be an unreplicated
@@ -455,29 +1038,36 @@ export declare class CadreNode implements SAppIdLookup {
455
1038
  * row is a no-op-equivalent).
456
1039
  *
457
1040
  * A node with no owner private key skips this entirely — it cannot re-sign
458
- * rows for other peers, and rows it merely holds are not its to re-issue. DELETEs
459
- * cannot be reconstructed here (a removed row leaves no local trace and the schema
460
- * carries no tombstone) — see the delete-path note in {@link drainPendingPeerWrites}.
1041
+ * rows for other peers, and rows it merely holds are not its to re-issue.
1042
+ * DELETEs are not reconstructed here — the `Revocation` tombstone sweep in
1043
+ * {@link drainPendingRevocations} covers them (a removed row leaves no
1044
+ * `CadrePeer` trace, but its tombstone is re-issuable).
461
1045
  */
462
1046
  private reconstructAuthoredMembership;
1047
+ /**
1048
+ * Body of {@link reconstructAuthoredMembership}'s sweep: re-issue each candidate
1049
+ * owner-authored row, returning how many were re-touched. A per-row failure is
1050
+ * logged and skipped; a teardown mid-sweep stops it.
1051
+ */
1052
+ private reissueAuthoredMembershipRows;
463
1053
  /**
464
1054
  * Drain the in-session write-while-alone queue: re-issue each pending owner
465
- * membership write now that the cohort can broadcast it. Sequential (no fan-out)
466
- * to avoid a thundering re-touch; an entry is cleared only on success, so a
467
- * failure (or a still-alone re-commit) leaves it queued for the next growth.
468
- *
469
- * - `authorize`: re-issue as an idempotent monotonic owner UPDATE
470
- * ({@link reissuePeerAuthorize}). Skipped (and cleared) if the row vanished
471
- * (raced a delete) or now carries a self-`Sig` (the peer self-published and owns
472
- * its republish).
473
- * - `remove` (security-relevant): a removePeer-while-alone physically removed the
474
- * row locally, so a re-issued DELETE matches nothing and does NOT propagate —
475
- * full delete durability requires a schema tombstone (deferred:
476
- * `control-delete-while-alone-tombstone`). We still attempt the re-issue
477
- * (harmless, and it propagates in the row-still-present case); the commit-alone
478
- * was already logged loudly in {@link noteControlWrite}.
1055
+ * authorize as an idempotent monotonic owner UPDATE
1056
+ * ({@link reissuePeerAuthorize}) now that the cohort can broadcast it.
1057
+ * Sequential (no fan-out) to avoid a thundering re-touch; an entry is cleared
1058
+ * only on success, so a failure (or a still-alone re-commit) leaves it queued
1059
+ * for the next growth. An entry is skipped (and cleared) if the row vanished
1060
+ * (raced a delete) or now carries a self-`Sig` (the peer self-published and owns
1061
+ * its republish). Removals are drained by {@link drainPendingRevocations}, not
1062
+ * here.
479
1063
  */
480
1064
  private drainPendingPeerWrites;
1065
+ /**
1066
+ * Body of {@link drainPendingPeerWrites}' loop. An entry is cleared only on
1067
+ * success (or when there is nothing left to re-issue); a failure is logged and
1068
+ * the entry stays queued for the next cohort growth.
1069
+ */
1070
+ private reissuePendingPeerWrites;
481
1071
  /**
482
1072
  * Re-issue an owner membership row as a monotonic owner UPDATE: bump
483
1073
  * `UpdatedAt` strictly above the stored value (and the wall clock) and re-sign via
@@ -549,8 +1139,10 @@ export declare class CadreNode implements SAppIdLookup {
549
1139
  * 2. `publicKey <-> peerId` binding — the stored key's libp2p identity is the
550
1140
  * requested peerId,
551
1141
  * 3. a `DeviceToken` row exists with a known {@link PushPlatform},
552
- * 4. self-signature verifies against the bound `CadrePeer.PublicKey`,
553
- * 5. freshness — `updatedAt` is positive and within `opts.maxAgeMs` (default:
1142
+ * 4. the row's `StampId` is NOT retired in `CadreControl.Revocation` — the
1143
+ * read-side half of the clear (see below),
1144
+ * 5. self-signature verifies against the bound `CadrePeer.PublicKey`,
1145
+ * 6. freshness — `updatedAt` is positive and within `opts.maxAgeMs` (default:
554
1146
  * no ceiling, since a push token is valid until it rotates).
555
1147
  *
556
1148
  * A peer that is not a current member, or whose token has no backing `CadrePeer`
@@ -600,7 +1192,7 @@ export declare class CadreNode implements SAppIdLookup {
600
1192
  private handleStrandHibernate;
601
1193
  /**
602
1194
  * Wake a strand. If it was hibernating (quiesced — no libp2p node), re-resolve
603
- * the cohort discovery seed and mode exactly as `launchStrand` does and rebuild
1195
+ * the cohort discovery seed exactly as `launchStrand` does and rebuild
604
1196
  * its runtime via the strand manager. If it is still live (e.g. waking an idle
605
1197
  * strand, which retains its resources), just flip the status. Overlapping wake
606
1198
  * triggers are coalesced upstream by `HibernationManager`, so this runs once
@@ -608,11 +1200,10 @@ export declare class CadreNode implements SAppIdLookup {
608
1200
  */
609
1201
  private handleStrandWake;
610
1202
  /**
611
- * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort inputs
612
- * first: the discovery seed may have grown and cohort membership may have
613
- * shifted the mode `bootstrap → networked` since the strand last ran. Shared by
1203
+ * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
1204
+ * first: the discovery seed may have grown since the strand last ran. Shared by
614
1205
  * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
615
- * both apply the same fresh seed/mode resolution. `resumeStrand` is idempotent
1206
+ * both apply the same fresh seed resolution. `resumeStrand` is idempotent
616
1207
  * (returns the live instance unchanged) as a backstop against double-resume.
617
1208
  */
618
1209
  private resumeStrandRuntime;
@@ -625,7 +1216,7 @@ export declare class CadreNode implements SAppIdLookup {
625
1216
  * realized as a resume → bounded window → re-hibernate-if-idle cycle that
626
1217
  * reuses the existing quiesce/resume primitives rather than a bespoke probe:
627
1218
  *
628
- * 1. Resume the strand (rebuild node + db, re-resolve cohort seed/mode) so
1219
+ * 1. Resume the strand (rebuild node + db, re-resolve the cohort seed) so
629
1220
  * its strand network can reach cohort peers — exactly as a wake does.
630
1221
  * 2. Hold it resumed for a bounded window, during which the app may drive
631
1222
  * reads (pull-on-read) and record activity.
@@ -672,6 +1263,14 @@ export declare class CadreNode implements SAppIdLookup {
672
1263
  /**
673
1264
  * Add a strand with its sApp configuration.
674
1265
  * The hosting application must provide the sApp schema when creating a strand.
1266
+ *
1267
+ * A rejected call leaves nothing running but DOES leave the sApp config
1268
+ * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1269
+ * automatic relaunch need it. So once the strand's row is visible on the control
1270
+ * network (via {@link publishStrand} or another member), a failed launch keeps
1271
+ * being re-attempted in the background — each failure re-emitting `strand:error`
1272
+ * — until it succeeds. {@link detachStrand} (reached via {@link stopStrand}) is
1273
+ * what abandons a strand for good.
675
1274
  */
676
1275
  addStrand(config: StrandConfig): Promise<StrandInstance>;
677
1276
  /**
@@ -689,7 +1288,7 @@ export declare class CadreNode implements SAppIdLookup {
689
1288
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
690
1289
  * so peer identity and owner key are one and the same. That key must be
691
1290
  * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
692
- * at genesis) or the schema's `Strand.Authorized` constraint rejects the write.
1291
+ * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
693
1292
  * Failing loudly here is intentional: a silently-unpublished strand would run
694
1293
  * as a local-only island that no peer could ever discover or join.
695
1294
  *
@@ -697,14 +1296,70 @@ export declare class CadreNode implements SAppIdLookup {
697
1296
  * {@link addStrand}).
698
1297
  * @param type - `'o'` for open (default) or `'c'` for closed.
699
1298
  * @param memberPrivateKey - Optional membership key for a closed strand.
700
- * @throws if the node is not started, exposes no owner signing key, or the
701
- * control DB rejects the (unauthorized) insert.
1299
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1300
+ * the control DB rejects the (unauthorized) insert.
702
1301
  */
703
1302
  publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<void>;
1303
+ /**
1304
+ * Remove this party's `Strand` row from the shared control database — the owner-signed
1305
+ * inverse of {@link publishStrand}.
1306
+ *
1307
+ * Party-wide, unlike {@link stopStrand}, which only stops the strand on THIS node: every
1308
+ * cadre node watching the table sees the row vanish on its next poll (default 5 s) and
1309
+ * stops its own instance. This node converges immediately — the method forces a watcher
1310
+ * poll and stops any still-running local instance before resolving. Removing the row
1311
+ * removes OUR party's participation only: other parties in the strand keep their own
1312
+ * rows and the strand network carries on, so no cross-party sign-off is involved — this
1313
+ * is an owner-signed control-plane write like every other, not "destroy the network".
1314
+ *
1315
+ * Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
1316
+ * this party's membership secret for that network, and it is stored nowhere else.
1317
+ * With it gone the party can never again admit a member to that closed strand, and a
1318
+ * re-published row would carry a DIFFERENT key that does not match the membership
1319
+ * already written into the strand's RBAC layer. The strand id itself is NOT
1320
+ * blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
1321
+ * stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
1322
+ * `Revocation` tombstone names the id, which the consent branch of
1323
+ * `Strand.AuthorizedInsert` refuses ever after). Outstanding `FormationInvite` rows
1324
+ * bound to the removed strand are unredeemable while the row is absent (the formation
1325
+ * recorder resolves the strand as missing and rejects cleanly).
1326
+ *
1327
+ * Convergence caveats, same class as {@link enrollValidationKey}'s: a sibling that has
1328
+ * not yet synced keeps running the strand until its own watcher observes the missing
1329
+ * row; a sibling whose `strandFilter` never admitted this strand never observes the
1330
+ * removal AT ALL and keeps running its instance indefinitely — opting out of watching a
1331
+ * strand is also opting out of its party-wide removal, so such a node's only stop is its
1332
+ * own local {@link stopStrand}/`unpublishStrand` call; and a removal committed while
1333
+ * ALONE (0 control connections) deletes the row local-only. The accompanying
1334
+ * `Revocation` tombstone IS queued and re-issued on the next cohort-growth edge
1335
+ * ({@link noteGuardedDelete}/{@link drainPendingRevocations}), so the stamp retirement
1336
+ * — and with it the consent re-seat foreclosure — does propagate; but the physical row
1337
+ * deletion cannot be replayed, and `queryStrands` reads raw (no retired-stamp filter),
1338
+ * so siblings that already hold the row keep running the strand until the collection
1339
+ * itself converges (logged loudly; see the delete-while-alone durability note in
1340
+ * docs/architecture.md).
1341
+ *
1342
+ * A no-op (no throw, no tombstone) when the row is already absent — but a
1343
+ * locally-running instance of that id is still stopped.
1344
+ *
1345
+ * NOTE: control-plane only — the strand's local durable storage is retained. Stopping the
1346
+ * instance closes the `StrandDatabase` and its libp2p node but purges no blocks, so a
1347
+ * closed strand's content stays readable on disk to anyone with the data directory. If a
1348
+ * caller ever needs removal to mean "and erase the local copy", that is a separate purge
1349
+ * step, not a widening of this method.
1350
+ *
1351
+ * @param strandId - The `Strand` row id to remove (as passed to {@link publishStrand}).
1352
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1353
+ * the signer is not an enrolled owner (the schema's `Strand.AuthorizedDelete` rejects
1354
+ * the write and the row survives). A rejection does NOT imply the row survived: the
1355
+ * local stop runs after the control-plane delete has already committed, so a failure
1356
+ * there throws over a completed removal.
1357
+ */
1358
+ unpublishStrand(strandId: string): Promise<void>;
704
1359
  /**
705
1360
  * Publish an owner-signed `FormationInvite` (open-invitation token) to the
706
1361
  * shared control database, so a later {@link formStrand} redemption can be
707
- * validated against it (the consent branch of `Strand.Authorized`).
1362
+ * validated against it (the consent branch of `Strand.AuthorizedInsert`).
708
1363
  *
709
1364
  * Counterpart to {@link createOpenInvitation}, which only mints the
710
1365
  * out-of-band {@link OpenInvitation} envelope: persisting the matching
@@ -729,28 +1384,234 @@ export declare class CadreNode implements SAppIdLookup {
729
1384
  validationUrl?: string;
730
1385
  strandId?: string;
731
1386
  }): Promise<void>;
1387
+ /**
1388
+ * Enroll an approver public key allowed to sign off on `ValidationUrl` redemptions.
1389
+ *
1390
+ * A `FormationInvite` carrying a `ValidationUrl` is only redeemable when the approval
1391
+ * that comes back from that URL is signed by a key present in `CadreControl.ValidationKey`
1392
+ * — this is how a party says which outside approver it trusts. Without an enrollment,
1393
+ * every such invitation is unredeemable.
1394
+ *
1395
+ * Signs with the same self-owner key as {@link publishStrand} (the ed25519 key behind
1396
+ * this node's PeerId, which must be an enrolled `OwnerKey`). Throws loudly if the node
1397
+ * isn't started, exposes no signing key, or the key is blank — a blank key would reach
1398
+ * the database and fail the signature CHECK as an opaque constraint error.
1399
+ *
1400
+ * Enrolling this node's OWN owner key is permitted and harmless: the domain/action tags
1401
+ * baked into each authorization digest mean an owner-key signature can never satisfy the
1402
+ * approval rule and an approval can never satisfy an owner rule. No guard is needed.
1403
+ *
1404
+ * NOTE: enrollment is replicated control state, so a key enrolled here is visible to a
1405
+ * sibling cadre node only once control replication converges. A redemption that arrives
1406
+ * at a node which has not caught up is refused as not-enrolled — the same convergence
1407
+ * gap the schema records on `Strand.StampId`. Enroll before circulating the invitations
1408
+ * that depend on the key.
1409
+ *
1410
+ * @param key - Approver public key to enroll (base64url ed25519 public key).
1411
+ */
1412
+ enrollValidationKey(key: string): Promise<void>;
1413
+ /**
1414
+ * Remove an approver public key.
1415
+ *
1416
+ * Narrows who may approve FUTURE redemptions ONLY. The schema's approval CHECKs run at
1417
+ * write time, so a join that was already approved by this key stays valid — removal is
1418
+ * not retroactive and does not re-examine committed rows.
1419
+ *
1420
+ * Rotation is therefore add-then-remove, in that order: removing the only enrolled key
1421
+ * while `ValidationUrl` invitations are outstanding makes every one of them unredeemable
1422
+ * until a new key is enrolled.
1423
+ *
1424
+ * Removing a key that is not enrolled is a silent no-op (no throw, no `Revocation`
1425
+ * tombstone) — see {@link ControlDatabase.deleteValidationKey}.
1426
+ *
1427
+ * @param key - Approver public key to remove (base64url ed25519 public key).
1428
+ */
1429
+ removeValidationKey(key: string): Promise<void>;
1430
+ /**
1431
+ * The approver keys currently enrolled, sorted. Read-only, so unlike
1432
+ * {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner signing
1433
+ * key — only a started node.
1434
+ */
1435
+ listValidationKeys(): Promise<string[]>;
1436
+ /**
1437
+ * Resolve the owner keypair every owner-signed control write needs — {@link publishStrand},
1438
+ * {@link unpublishStrand}, {@link publishFormationInvite}, {@link enrollValidationKey},
1439
+ * {@link removeValidationKey}
1440
+ * — failing loudly in one two-part shape (not started / no signing key, naming owner
1441
+ * genesis as the fix). Narrows `controlDatabase` for the caller: a non-null return means
1442
+ * `this.controlDatabase` is non-null too.
1443
+ *
1444
+ * @param action - Infinitive phrase naming the attempted write, e.g. `'enroll a
1445
+ * validation key'`; interpolated into both messages.
1446
+ */
1447
+ private requireOwnerSigningKey;
732
1448
  /**
733
1449
  * Shared strand launch path for both the explicit (`addStrand`) and the
734
1450
  * control-discovered (`handleStrandAdded`) entry points. Resolves the cohort
735
- * seed, selects the mode, starts the strand, and registers it with the
736
- * hibernation manager before emitting `strand:started`.
1451
+ * seed, starts the strand, and registers it with the hibernation manager
1452
+ * before emitting `strand:started`.
1453
+ *
1454
+ * Idempotent no-op when the strand manager already tracks `strand.Id` — the
1455
+ * ordinary founding sequence is `addStrand` (starts it locally) followed by
1456
+ * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
1457
+ * rediscovers a row it already started and calls this again via
1458
+ * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
1459
+ * (already-connected) cohort seed and re-emit `strand:started` for an
1460
+ * instance that never stopped. The guard runs before the cohort-seed RPC
1461
+ * fan-out, so a rediscovery costs nothing beyond the map lookup.
737
1462
  */
738
1463
  private launchStrand;
739
1464
  /**
740
- * Resolve a strand's discovery seed. Membership (and thus mode) comes from the
741
- * control network's CadrePeer rows; the **strand-network** bootstrap addresses
742
- * are resolved on demand over the control mesh via the strand-addr RPC —
743
- * deliberately NOT from `CadrePeer.Multiaddr`, which carries *control*
744
- * addresses that must not seed the strand mesh.
1465
+ * Resolve a strand's discovery seed — the dialable strand-network multiaddr
1466
+ * strings for cohort siblings. Membership comes from the control network's
1467
+ * CadrePeer rows; the **strand-network** bootstrap addresses are resolved on
1468
+ * demand over the control mesh via the strand-addr RPC — deliberately NOT
1469
+ * from `CadrePeer.Multiaddr`, which carries *control* addresses that must not
1470
+ * seed the strand mesh.
745
1471
  *
746
1472
  * Only siblings we already hold an open control connection to are RPC'd: they
747
1473
  * are the ones that can answer right now, and dialing them by peerId reuses the
748
1474
  * live connection. When no connected sibling yet runs the strand the seed is
749
- * empty (`[]`) — mode still follows membership, and the empty seed self-heals
750
- * on the next resume / check-in pass. Returns an empty seed when the control DB
751
- * or node is absent (not yet started / torn down).
1475
+ * empty (`[]`) — the empty seed self-heals on the next resume / check-in pass.
1476
+ * Returns an empty seed when the control DB or node is absent (not yet
1477
+ * started / torn down).
1478
+ *
1479
+ * When `delegatePeerId` is given (a strand launch/resume is imminent), the
1480
+ * pass doubles as the delegate ANNOUNCEMENT: our circuit relays are merged
1481
+ * into the RPC targets and every request carries the delegate peerId, so each
1482
+ * receiver records an admission grant BEFORE `libp2p.start()` (re-)dials the
1483
+ * relay reservation — the gate denial there is fatal, not degraded. A
1484
+ * party-member relay answers the RPC (its control node admits us as a
1485
+ * member); a dedicated ops/ relay does not speak the protocol and the
1486
+ * per-peer failure folds to `[]` — harmless, it has no membership gate and
1487
+ * needs no grant. The relay's direct addr rides along as the dial fallback
1488
+ * for a relay we are not yet connected to.
752
1489
  */
753
1490
  private resolveCohortSeed;
1491
+ /**
1492
+ * The co-cadre siblings worth sending a strand-addr RPC to right now: cohort
1493
+ * members (self excluded) we already hold an open control connection to. They
1494
+ * are the ones that can answer immediately, and dialing them by peerId reuses
1495
+ * the live connection instead of opening a second one.
1496
+ *
1497
+ * Shared by {@link resolveCohortSeed} (launch/resume) and
1498
+ * {@link refreshStrandPeerAddrs} (the periodic pass) so both ask the same set.
1499
+ * Empty when the control DB or node is absent (not yet started / torn down).
1500
+ *
1501
+ * The read below is issued even when the node holds no control connection and
1502
+ * the answer is therefore empty by construction. That is deliberate on the
1503
+ * launch path: `control-database-solo-warm-start.spec.ts` exists to prove this
1504
+ * exact read does not stall as an embedder's FIRST awaited control operation,
1505
+ * on a warm cohort no one can reach. A caller that re-enters often enough for
1506
+ * the read to matter should skip the call itself, as
1507
+ * {@link refreshStrandPeerAddrs} does.
1508
+ *
1509
+ * NOTE: this read is UNBOUNDED and sits on the critical path an embedding app
1510
+ * awaits during startup — `addStrand` cannot resolve until it does, and it is
1511
+ * the first control operation an embedder's boot order issues (before genesis,
1512
+ * before seed bootstrap). Measured fine today: the warm-start-alone shape a
1513
+ * report pointed at — a `CadrePeer` list naming peers that are all gone, read
1514
+ * off real files after a restart — completes in milliseconds, and
1515
+ * `control-database-solo-warm-start.spec.ts` covers it under a deadline. If a
1516
+ * control read ever CAN stall (a transactor change that consults the network
1517
+ * for a local row, a storage backend with blocking I/O), give this one a
1518
+ * timeout — and decide then whether the breach fails `addStrand` or degrades
1519
+ * to an empty seed, because those promise callers different things.
1520
+ */
1521
+ private connectedSiblingTargets;
1522
+ /**
1523
+ * The circuit relays this node's strand nodes would reserve through: the
1524
+ * union of the RESOLVED listen addrs' circuits — a hand-written
1525
+ * `network.listenAddrs` circuit entry or a `network.relayAddrs` entry, which
1526
+ * the strand node inherits verbatim either way — and the control node's own
1527
+ * live `/p2p-circuit` multiaddrs (a reservation this node discovered rather
1528
+ * than configured).
1529
+ *
1530
+ * NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
1531
+ * source) gets no announcement, and a membership-gated one will deny it; fine
1532
+ * now, every realistic topology feeds one of the two sources.
1533
+ */
1534
+ private circuitRelayTargets;
1535
+ /**
1536
+ * Record the announce timestamps {@link refreshDelegateGrants} throttles on,
1537
+ * for every relay a delegate-carrying announce pass dialed.
1538
+ *
1539
+ * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
1540
+ * per-peer failure to `[]` and reports no per-peer success, and threading
1541
+ * success out would change its API for little gain. A failed INITIAL announce
1542
+ * is fatal-at-start anyway (the relay denies the reservation,
1543
+ * `libp2p.start()` throws, and wake/check-in re-resume retries with a fresh
1544
+ * announce); a failed REFRESH retries within `DELEGATE_GRANT_TTL_MS / 2`
1545
+ * (15 min), still inside the 30 min TTL.
1546
+ */
1547
+ private recordDelegateAnnounces;
1548
+ /**
1549
+ * Re-announce every running strand's delegate peerId to this node's circuit
1550
+ * relays, throttled to once per `DELEGATE_GRANT_TTL_MS / 2` per
1551
+ * (relay, strand). A grant must outlive the reservation: a dropped relay
1552
+ * connection makes the strand's circuit-relay transport re-dial and face the
1553
+ * connection gate again, so the relay must still hold a live grant then.
1554
+ * RELAY targets only — siblings get their announce on every launch/resume
1555
+ * seed pass, and a grant only matters where a reservation can be re-dialed.
1556
+ *
1557
+ * Strands announce CONCURRENTLY: this runs ahead of the reconcile pass's
1558
+ * sibling enumeration, and one unreachable relay costs a dial timeout per
1559
+ * target, which must not stack up per strand.
1560
+ */
1561
+ private refreshDelegateGrants;
1562
+ /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
1563
+ private announceDelegateToDueRelays;
1564
+ /**
1565
+ * Re-resolve each running strand's SIBLING addresses over the control mesh and
1566
+ * merge them into that strand's own libp2p address book, throttled to once per
1567
+ * {@link STRAND_PEER_ADDR_REFRESH_MS} per strand.
1568
+ *
1569
+ * Without this, a strand's address book is written exactly once — the
1570
+ * launch/resume seed — and everything below cadre-core that dials a strand peer
1571
+ * by bare peer id (Optimystic's cluster and repo clients, FRET ping/announce)
1572
+ * loses its address for any sibling it is not currently connected to: a sibling
1573
+ * that restarted its strand node or rotated its relay reservation is never
1574
+ * re-resolved, and even the original seed addresses fall off at the peerStore's
1575
+ * one-hour expiry (see `peer-addr-book.ts`).
1576
+ *
1577
+ * Distinct from {@link refreshDelegateGrants}, deliberately: that pass covers
1578
+ * RELAYS on a `DELEGATE_GRANT_TTL_MS / 2` throttle to keep circuit-relay
1579
+ * admission grants alive, this one covers SIBLINGS on an address-expiry
1580
+ * throttle to keep the address book warm. They overlap only in that both carry
1581
+ * `delegatePeerId`, so a sibling that also runs a relay gets its grant
1582
+ * refreshed here as a side effect. Merging them would tie an admission-grant
1583
+ * TTL to an address-expiry window that has nothing to do with it.
1584
+ *
1585
+ * Strands refresh CONCURRENTLY, like `refreshDelegateGrants`, so one
1586
+ * unreachable sibling's dial timeout does not stack up per strand.
1587
+ */
1588
+ private refreshStrandPeerAddrs;
1589
+ /**
1590
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, then
1591
+ * merge their answers into this strand's address book. Errors are logged and
1592
+ * swallowed so one strand's failure never costs the others their refresh.
1593
+ */
1594
+ private refreshOneStrandPeerAddrs;
1595
+ /**
1596
+ * Merge strand-network addresses into ONE strand node's libp2p address book,
1597
+ * attributed per peer. Best-effort throughout: an address-book write must never
1598
+ * fail a launch, a resume, or a reconcile pass.
1599
+ *
1600
+ * `addrs` is the peer-agnostic union the strand-addr RPC returns, so
1601
+ * {@link groupAddrsByPeerId} attributes each entry to the **strand transport**
1602
+ * peerId in its final `/p2p/` component — never the sibling's control peerId,
1603
+ * which names a different libp2p node entirely. Entries that name no peer are
1604
+ * dropped there and counted here, once per pass.
1605
+ *
1606
+ * NOTE: a member could answer with arbitrary multiaddrs bound to arbitrary peer
1607
+ * ids and poison this address book. That is the same exposure the launch-time
1608
+ * `bootstrapNodes` seeding already accepts, and the cost is bounded: an address
1609
+ * grants no authority, the dialed peer authenticates by peer id at the
1610
+ * handshake, and a bad entry costs one failed dial that ages out at the
1611
+ * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
1612
+ * `backlog/strand-network-nat-relay-reachability`.
1613
+ */
1614
+ private mergeStrandPeerAddrs;
754
1615
  /**
755
1616
  * The local strand instance's dialable strand-network multiaddrs for the
756
1617
  * strand-addr RPC, ordered signaling-first (reusing the control node's
@@ -762,9 +1623,26 @@ export declare class CadreNode implements SAppIdLookup {
762
1623
  */
763
1624
  private getStrandMultiaddrs;
764
1625
  /**
765
- * Remove a strand
1626
+ * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
1627
+ * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
1628
+ * intact, so on the next node restart (or watcher poll) the strand is rediscovered and
1629
+ * surfaces as `strand:discovered` again. Party-wide removal is {@link unpublishStrand}.
766
1630
  */
767
- removeStrand(strandId: string): Promise<void>;
1631
+ stopStrand(strandId: string): Promise<void>;
1632
+ /**
1633
+ * Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
1634
+ * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config, stop
1635
+ * the instance, emit `strand:stopped`. Touches no control-plane row — which side of the
1636
+ * removal the node is on is the caller's concern, not this method's.
1637
+ *
1638
+ * The stop + emit are skipped when the strand manager holds no instance for `strandId` —
1639
+ * e.g. a party owner that published a strand's row but never ran it locally, or an
1640
+ * explicit {@link stopStrand} for an id this node never started. `hibernationManager`
1641
+ * untrack and the `sAppConfigs` delete stay unconditional (both are no-ops when there is
1642
+ * nothing to remove), so a launch that failed before an instance was ever tracked still
1643
+ * gets its stray sApp config cleared.
1644
+ */
1645
+ private detachStrand;
768
1646
  /**
769
1647
  * Record activity on a strand (resets hibernation timer).
770
1648
  *
@@ -841,10 +1719,58 @@ export declare class CadreNode implements SAppIdLookup {
841
1719
  * Get the control network node (for advanced use)
842
1720
  */
843
1721
  getControlNode(): Libp2p | null;
1722
+ /**
1723
+ * Start keeping a relay reservation: dial the given relay(s) from the control
1724
+ * node, ask the first one that answers for a reservation slot, wait until the
1725
+ * resulting `/p2p-circuit` address makes this node dialable — and keep a
1726
+ * supervisor running that re-drives whenever the reservation is later lost.
1727
+ *
1728
+ * For nodes that listen on the bare `/p2p-circuit` SEARCH addr (a browser tab)
1729
+ * rather than a configured `network.relayAddrs` circuit listener — see
1730
+ * `relay-reservation.ts` for why the two are alternatives and why configuring
1731
+ * both is a regression. Opt-in: nothing calls this for the CLI/host nodes.
1732
+ *
1733
+ * Resolves once the FIRST attempt has settled, exactly as it did when it drove
1734
+ * once; the retries continue in the background until {@link stop} or the next
1735
+ * `reserveRelays` call. Fail-soft — never throws. An unreachable relay, a
1736
+ * missing control node, or a timeout all resolve to a non-`reserved` status, so
1737
+ * a caller can await this during startup without a dead relay aborting the node.
1738
+ *
1739
+ * Passing an empty list stops the supervisor and resets the posture to `none`
1740
+ * without dialing or waiting.
1741
+ */
1742
+ reserveRelays(addrs: string[], opts?: RelayReservationSupervisorOptions): Promise<RelayReservationState>;
1743
+ /**
1744
+ * The node's CURRENT relay-reservation posture, recomputed from the control
1745
+ * node's live multiaddrs on every call.
1746
+ *
1747
+ * Deliberately not memoised: a reservation can be lost after
1748
+ * {@link reserveRelays} succeeds (the relay restarts, the connection drops) and
1749
+ * a cached `reserved` would let a caller mint invitations carrying circuit
1750
+ * addresses that no longer route.
1751
+ *
1752
+ * A lost reservation now recovers on its own — the supervisor {@link reserveRelays}
1753
+ * starts re-drives on a backoff, reporting `retrying` meanwhile. `error` here
1754
+ * means nothing is going to try again: no supervisor, or no control node.
1755
+ */
1756
+ getRelayReservationState(): RelayReservationState;
844
1757
  /**
845
1758
  * Get the control database (for advanced queries)
846
1759
  */
847
1760
  getControlDatabase(): ControlDatabase | null;
1761
+ /**
1762
+ * Open control-network connections right now. A lower-bound proxy for replication
1763
+ * reach: 0 connections ⇒ a control write commits local-only. The private
1764
+ * `committedAlone` write-while-alone seam is defined in terms of this.
1765
+ *
1766
+ * It approximates the precise signal (the block's cluster size), and a caller that
1767
+ * samples it AFTER a write samples a slightly wider window than `committedAlone`
1768
+ * does inside the write itself. It exists so an embedder can warn an owner before a
1769
+ * control write that this machine currently sees none of its siblings, and report
1770
+ * after one that the write may not have travelled — a warning shown unconditionally
1771
+ * is a warning people learn to ignore.
1772
+ */
1773
+ getControlConnectionCount(): number;
848
1774
  /**
849
1775
  * Force a poll of the strand watcher (for testing)
850
1776
  */
@@ -872,7 +1798,10 @@ export declare class CadreNode implements SAppIdLookup {
872
1798
  */
873
1799
  private resolveInviteAddresses;
874
1800
  /**
875
- * Enumerate the cadre's `CadrePeer` membership.
1801
+ * Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
1802
+ * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
1803
+ * never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
1804
+ * revoked peer is not addressable either.
876
1805
  */
877
1806
  listMembers(): Promise<Array<{
878
1807
  peerId: string;
@@ -883,30 +1812,72 @@ export declare class CadreNode implements SAppIdLookup {
883
1812
  *
884
1813
  * This is the ADDRESSABLE surface ("do I have a dialable address record for this
885
1814
  * peer") — it includes this node's own self-published row. Address resolution and
886
- * push fan-out use this. The trust-facing gate is {@link isAuthorizedMember}.
1815
+ * push fan-out use this. A peer whose stamp is retired in `CadreControl.Revocation`
1816
+ * is excluded here too (the filter lives in
1817
+ * {@link ControlDatabase.queryCadrePeers}): revocation removes a peer from the
1818
+ * addressable surface, not only the trust-facing one, so a revoked peer is no
1819
+ * longer dialed, RPC'd, or handed out as an address. The trust-facing gate is
1820
+ * {@link isAuthorizedMember}.
887
1821
  */
888
1822
  isMember(peerId: string): Promise<boolean>;
889
1823
  /**
890
1824
  * Enumerate the party's AUTHORIZED members — the trust-facing set, distinct from
891
- * the addressable set ({@link listMembers}). Excludes this node itself: a node
892
- * publishes its own `CadrePeer` address row so its dialable address rides in seeds,
893
- * but "self" is not a peer this node authorized. The control-network wake and
1825
+ * the addressable set ({@link listMembers}). A peer is authorized iff ALL hold:
1826
+ *
1827
+ * 1. it is not this node itself — a node publishes its own `CadrePeer` address
1828
+ * row so its dialable address rides in seeds, but "self" is not a peer this
1829
+ * node authorized;
1830
+ * 2. its `CadrePeer` row carries a complete voucher (`StampId`, `VouchOwner`,
1831
+ * `VouchSig` all non-null);
1832
+ * 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
1833
+ * ({@link getTrustedOwnerStore}) — never the replicated `OwnerKey` table,
1834
+ * which any stranger can genesis-pollute; and
1835
+ * 4. `VouchSig` verifies as that owner's signature over the row's voucher
1836
+ * digest ({@link verifyCadrePeerVoucher}), so the anchored owner really
1837
+ * vouched THIS peer id under THIS row's nonce; and
1838
+ * 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
1839
+ * upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
1840
+ * before ANY reader (this predicate included) sees them, so a row resurrected
1841
+ * by replaying the captured admission approval on a node that had not yet
1842
+ * converged on the tombstone (the write-time `NotRevoked` CHECK only sees
1843
+ * local rows) is still inert to every reader that has the tombstone.
1844
+ *
1845
+ * Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
1846
+ * not-yet-enrolled node authorizes no one), a null/partial voucher, an
1847
+ * unanchored `VouchOwner`, or a bad signature all yield "not authorized" —
1848
+ * having an address row is NOT membership. The control-network wake and
894
1849
  * strand-address gates consult this set, NOT the addressable one.
895
1850
  *
896
- * NOTE: this is the addressable set minus self for now. The real trust predicate
897
- * (voucher recorded on the row ∈ node-local trusted-owner anchor, signature
898
- * verified) lands with the `membership-node-local-owner-anchor` /
899
- * `membership-authorized-predicate-and-gates` tickets; keeping the filter in this one
900
- * method means that change touches a single place.
1851
+ * NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
1852
+ * in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
1853
+ * legit member goes un-authorized on readers that only pin the new key. Full
1854
+ * rotation handling (re-vouch on rotate) is the
1855
+ * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
901
1856
  */
902
1857
  listAuthorizedMembers(): Promise<Array<{
903
1858
  peerId: string;
904
1859
  multiaddr: string | null;
905
1860
  }>>;
1861
+ /**
1862
+ * Checks 2–4 of the authorized-membership predicate (see
1863
+ * {@link listAuthorizedMembers}) for one `CadrePeer` row: complete voucher,
1864
+ * `VouchOwner` in the node-local anchor, signature valid over the row's
1865
+ * (PeerId, StampId) voucher digest.
1866
+ *
1867
+ * NOTE: verifies the ed25519 signature on every call (no memo of already-
1868
+ * verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
1869
+ * devices and the gates run per inbound request, so this is cheap today; if
1870
+ * membership or gate traffic ever grows, cache verified triples keyed on the
1871
+ * row's `StampId` (which rotates with every re-vouch).
1872
+ */
1873
+ private hasAnchoredVoucher;
906
1874
  /**
907
1875
  * Probe whether a given peer is an AUTHORIZED party member (see
908
1876
  * {@link listAuthorizedMembers}) — the gate the control-network wake and
909
- * strand-address responders consult, NOT {@link isMember} (the addressable surface).
1877
+ * strand-address responders consult, NOT {@link isMember} (the addressable
1878
+ * surface). Deliberately scans the full membership (one `CadrePeer` query)
1879
+ * rather than adding a single-row read path: cadres are small, and one code
1880
+ * path keeps the predicate impossible to drift from the list.
910
1881
  */
911
1882
  isAuthorizedMember(peerId: string): Promise<boolean>;
912
1883
  /**
@@ -935,6 +1906,68 @@ export declare class CadreNode implements SAppIdLookup {
935
1906
  * Get the seed bootstrap service (for advanced use)
936
1907
  */
937
1908
  getSeedBootstrapService(): SeedBootstrapService | null;
1909
+ /**
1910
+ * The event callbacks every {@link SeedBootstrapService} this node owns is
1911
+ * wired with — shared by the owner-capable service ({@link initializeSeedBootstrap})
1912
+ * and the listener-only one ({@link enableSeedListener}) so the two cannot drift.
1913
+ *
1914
+ * A seed applied by the INBOUND protocol handler writes no `CadrePeer` row of
1915
+ * its own (it merges the libp2p peer store and dials owners), so the automatic
1916
+ * write-driven refresh never fires for it — yet applying it can ANCHOR a new
1917
+ * owner key, which flips rows already present from unauthorized to authorized.
1918
+ * Hence the explicit refresh here; without it a peer the freshly anchored owner
1919
+ * vouched for stays denied until the next timed cohort reconcile.
1920
+ */
1921
+ private seedEventCallbacks;
1922
+ /**
1923
+ * Re-materialize the authorized-peer snapshot that the fail-closed per-stream
1924
+ * control-DB gate ({@link authorizeInboundControlStream}) judges against.
1925
+ *
1926
+ * NO `CadrePeer` writer needs to call this: the control database notifies it
1927
+ * after every committed member-row write (`ControlDatabase.mutateCadrePeer`,
1928
+ * wired in {@link start}), which is exactly what makes the refresh automatic
1929
+ * rather than a caller obligation. It stays public for the changes that write
1930
+ * NO row locally and so raise no notification:
1931
+ *
1932
+ * - a membership row that arrived by REPLICATION (otherwise picked up on the
1933
+ * next timed cohort reconcile — bounded staleness by design), and
1934
+ * - a newly anchored trusted owner key ({@link applySeed}), which flips rows
1935
+ * ALREADY present from unauthorized to authorized without touching them.
1936
+ *
1937
+ * Coalescing: marks the snapshot stale and resolves once a refresh that began
1938
+ * after this call has completed, so a caller that awaits it always observes
1939
+ * its own change. Concurrent callers share one read. Idempotent and
1940
+ * best-effort (a failed read keeps the previous snapshot); never rejects.
1941
+ */
1942
+ refreshMembershipGate(reason?: string): Promise<void>;
1943
+ /**
1944
+ * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
1945
+ * until the stale flag stays clear, then release the slot.
1946
+ *
1947
+ * NOTE: correct only because {@link membershipGateDirty} is guaranteed TRUE at
1948
+ * entry — its sole caller sets it synchronously, with no await in between. Were
1949
+ * this ever invoked with the flag already clear, the body would never suspend,
1950
+ * so the `finally` would null the slot BEFORE the caller's `??=` filled it, and
1951
+ * the slot would be left holding an already-settled drain that every later
1952
+ * refresh reuses — an unbreakable await/re-check spin. If a second caller is
1953
+ * ever added, have it mark the flag first (or assert it here).
1954
+ */
1955
+ private drainMembershipGate;
1956
+ /**
1957
+ * Collapse a burst of `CadrePeer` writes into ONE gate refresh at scope exit.
1958
+ *
1959
+ * For loops that re-touch many rows (the write-while-alone drains), where a
1960
+ * per-row refresh would mean one full membership read per row for a snapshot
1961
+ * that only has to be correct once the loop settles.
1962
+ *
1963
+ * The depth counter is instance-level, so it also suppresses a genuinely
1964
+ * CONCURRENT external write's refresh for the life of the scope: that writer's
1965
+ * promise resolves before its peer is admitted, and admission lands at scope
1966
+ * exit instead. Acceptable — scope exit is milliseconds away (bounded by the
1967
+ * drain), versus the ~15 s reconcile interval that was the alternative before
1968
+ * any of this existed — but it is the reason these scopes stay short and rare.
1969
+ */
1970
+ private deferMembershipGateRefresh;
938
1971
  /**
939
1972
  * Authorize a new peer to join the cadre.
940
1973
  * Signs the peer ID with the owner key and inserts into CadrePeer table.
@@ -967,6 +2000,13 @@ export declare class CadreNode implements SAppIdLookup {
967
2000
  applySeed(seed: ControlNetworkSeed, options?: {
968
2001
  trustPolicy?: SeedTrustPolicy;
969
2002
  }): Promise<ApplySeedResult>;
2003
+ /**
2004
+ * Post-process an {@link applySeed} outcome: retain the seed's owner peers as
2005
+ * cold-start bootstrap targets, and surface a seed that was accepted but whose
2006
+ * every owner dial failed — the node is now seeded yet unconnected, and the
2007
+ * cold-start reconcile branch is what gets it out of that state.
2008
+ */
2009
+ private noteAppliedSeed;
970
2010
  /**
971
2011
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
972
2012
  */