@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  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 +918 -53
  9. package/dist/cadre-node.js +1843 -190
  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-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -2,12 +2,15 @@ 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';
13
16
  type EventHandler<T> = (data: T) => void;
@@ -31,6 +34,15 @@ export declare class CadreNode implements SAppIdLookup {
31
34
  * launch) reads this resolved field, never `config.privateKey` directly.
32
35
  */
33
36
  private identityKey;
37
+ /**
38
+ * Node-local, NON-replicated trusted-owner anchor (see
39
+ * `trusted-owner-store.ts`). Constructed (or adopted from
40
+ * `config.trustedOwners.store`) by {@link initializeTrustedOwnerStore} during
41
+ * {@link start}; deliberately NOT cleared by {@link cleanup}, so an in-memory
42
+ * anchor survives a stop()→start() cycle of the same node instance. Never
43
+ * sourced from replicated control state.
44
+ */
45
+ private trustedOwnerStore;
34
46
  private controlNode;
35
47
  private controlDatabase;
36
48
  private strandWatcher;
@@ -70,6 +82,25 @@ export declare class CadreNode implements SAppIdLookup {
70
82
  */
71
83
  private windowWaiters;
72
84
  private eventHandlers;
85
+ /**
86
+ * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through.
87
+ * Empty (the default) means nobody asked, so {@link getRelayReservationState}
88
+ * reports `none` — this whole surface is opt-in and dormant for the CLI/host
89
+ * nodes, which use the configured `network.relayAddrs` route instead.
90
+ */
91
+ private relayReserveAddrs;
92
+ /**
93
+ * The running retry loop for {@link relayReserveAddrs}, or `null` when nobody
94
+ * asked for a reservation / there was no control node to supervise. It owns the
95
+ * in-flight flag, the last failure and the next-attempt time; this class only
96
+ * starts it, stops it and reads it.
97
+ */
98
+ private relayReserveSupervisor;
99
+ /**
100
+ * Reason {@link reserveRelays} produced no reservation when there is no
101
+ * supervisor to hold one — i.e. the pre-start `control node unavailable` case.
102
+ */
103
+ private relayReserveError;
73
104
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
74
105
  private sAppConfigs;
75
106
  /**
@@ -79,6 +110,106 @@ export declare class CadreNode implements SAppIdLookup {
79
110
  * control-network node never needs to dial back to the manager.
80
111
  */
81
112
  private latestInviteAddresses;
113
+ /**
114
+ * Epoch ms until which the control-network inbound gate admits
115
+ * not-yet-authorized peers (see {@link admitInboundControlConnection} /
116
+ * {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
117
+ * {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
118
+ * inside an outstanding invite's validity must not strand the invitee.
119
+ *
120
+ * NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
121
+ * the owner re-mints or the invitee is authorized. Fine while invites are
122
+ * short-lived and redeemed promptly; if long-lived invites or restart-prone
123
+ * hosts become normal, persist the window (or derive it from the issued-invite
124
+ * records `SeedBootstrapService` already keeps).
125
+ */
126
+ private enrollmentWindowUntil;
127
+ /**
128
+ * Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
129
+ * {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
130
+ */
131
+ private bootstrapPeerIds;
132
+ /**
133
+ * Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
134
+ * {@link listAuthorizedMembers} result), consulted by the per-stream
135
+ * control-DB gate ({@link authorizeInboundControlStream}). That predicate
136
+ * runs on EVERY inbound Optimystic control-DB stream and must never await a
137
+ * control-DB read: those reads pull blocks over the very protocols the
138
+ * predicate gates, which closes a circular wait that ends in mutual denial
139
+ * (the upstream gate is fail-closed on timeout, unlike the fail-open
140
+ * connection gater). So the set is refreshed OUT OF BAND instead — after
141
+ * start, on every {@link reconcileControlCohort} pass (15s cadence), and
142
+ * immediately after every LOCAL membership mutation so a just-vouched peer
143
+ * is admitted without waiting for the timer. That last refresh is AUTOMATIC:
144
+ * the control database notifies {@link refreshMembershipGate} after every
145
+ * committed `CadrePeer` write (see `ControlDatabase.mutateCadrePeer`), so no
146
+ * writer has to remember. A change that arrives by REPLICATION is picked up
147
+ * on the next timed refresh — bounded staleness, acceptable because this gate
148
+ * is defense in depth: rows an unadmitted peer manages to write are still
149
+ * disbelieved at read time by the voucher-anchored predicate.
150
+ */
151
+ private authorizedControlPeers;
152
+ /**
153
+ * Coalescing state for {@link refreshMembershipGate}: a pending "the snapshot
154
+ * is stale" flag, the single in-flight drain that consumes it, and the depth
155
+ * of open {@link deferMembershipGateRefresh} scopes (a burst of writes inside
156
+ * one scope collapses to a single refresh at scope exit).
157
+ */
158
+ private membershipGateDirty;
159
+ private membershipGateDrain;
160
+ private membershipGateDeferDepth;
161
+ /**
162
+ * In-memory delegate admission grants (see `delegate-admission.ts`): the
163
+ * strand-node transport peerIds this party's members have announced over the
164
+ * strand-addr RPC, admitted at the CONNECTION level so a member's NAT'd
165
+ * strand node can hold a circuit-relay reservation on this node. Consulted
166
+ * ONLY by {@link admitInboundControlConnection}; the fail-closed per-stream
167
+ * gate ({@link authorizeInboundControlStream}) never honors it.
168
+ */
169
+ private readonly delegateAdmission;
170
+ /**
171
+ * When this node last ANNOUNCED a delegate, keyed `<targetPeerId>\n<strandId>`
172
+ * (the peer announced TO, not the delegate). Throttles
173
+ * {@link refreshDelegateGrants} to once per `DELEGATE_GRANT_TTL_MS / 2` per
174
+ * (relay, strand) so the 15 s reconcile tick never becomes per-tick RPC
175
+ * chatter; the launch/resume announce passes record here too. Keys whose
176
+ * strand is no longer running are pruned on each reconcile pass.
177
+ */
178
+ private readonly delegateAnnounceAt;
179
+ /**
180
+ * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
181
+ * this node has applied, keyed by peer id, valued by the seed's multiaddr
182
+ * strings (parsed lazily, at dial time). Written by
183
+ * {@link recordSeedBootstrapPeers}; read only by
184
+ * {@link dialColdStartBootstrap} while the control database still has no
185
+ * siblings to dial.
186
+ *
187
+ * A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
188
+ * targets must outlive the process: a node seeded into a party it could not
189
+ * reach has nothing else on disk naming that party's addresses (`applySeed`
190
+ * writes no control row, and `CadrePeer` fills in only after a connection
191
+ * succeeds), so an in-memory-only set left it stranded permanently across a
192
+ * restart. Constructed (or adopted from `config.bootstrapPeers.store`) by
193
+ * {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
194
+ * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
195
+ * node instance — same lifecycle as {@link trustedOwnerStore}. Durability
196
+ * depends on the injected backend: Node gets a file-backed store from the CLI,
197
+ * RN/browser stay ephemeral for now.
198
+ *
199
+ * Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
200
+ * reads for the steady-state path. The peer store is shared with everything
201
+ * libp2p discovers, so "dial every entry" would grow into dialing arbitrary
202
+ * discovered peers as the node lives longer; this store holds exactly the peers
203
+ * an owner-signed, trust-anchored seed nominated as owners. `applySeed` merges
204
+ * the same addresses into the peer store as well, so the two never disagree —
205
+ * this one is just the precisely-scoped subset.
206
+ *
207
+ * A later seed OVERWRITES an entry rather than merging, so a re-seed after an
208
+ * owner's address changes replaces the stale address instead of accumulating
209
+ * dead ones. Entries are never evicted: they are the node's only way back into
210
+ * the party if it is ever stranded again.
211
+ */
212
+ private bootstrapPeerStore;
82
213
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
83
214
  private selfRegistrationTimer;
84
215
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -107,15 +238,38 @@ export declare class CadreNode implements SAppIdLookup {
107
238
  */
108
239
  private registerSelfInFlight;
109
240
  /**
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}.
241
+ * Re-replication queue for owner `authorizePeer` writes that committed while
242
+ * this node was alone — `controlNode.getConnections().length === 0` at write
243
+ * time means the Optimystic commit was local-only (the block's cluster was ≤1)
244
+ * and never broadcast. Maps the affected subject peerId → 'authorize', to
245
+ * re-issue once the cohort grows. Removals are NOT tracked here — a delete
246
+ * leaves a `Revocation` tombstone, tracked by {@link pendingRevocations}; a
247
+ * removePeer also clears any queued authorize for the same subject (the row is
248
+ * gone, so re-issuing the insert would be wrong). Drained on the 0→≥1
249
+ * control-connection transition by {@link drainPendingControlReplication}.
117
250
  */
118
251
  private pendingPeerWrites;
252
+ /**
253
+ * Re-replication queue for `Revocation` tombstones that committed while this
254
+ * node was alone, keyed on the retired `StampId`. Fed by the control DB's
255
+ * committed-delete seam ({@link noteGuardedDelete}), so one queue covers all
256
+ * four guarded tables (`CadrePeer` / `DeviceToken` / `Strand` /
257
+ * `ValidationKey`). Drained (owner-signed `ReissuedAt` bump, which re-broadcasts
258
+ * the tombstone) by {@link drainPendingRevocations}. Cleared on stop alongside
259
+ * {@link pendingPeerWrites} — the tombstone rows are durable, so the next
260
+ * lifetime's first-growth sweep re-covers anything dropped here.
261
+ */
262
+ private pendingRevocations;
263
+ /**
264
+ * Guards the once-per-lifetime first-growth revocation sweep (re-issue EVERY
265
+ * locally-held tombstone, covering removals from before this node started).
266
+ * Deliberately separate from {@link reconstructedLocalOnlyWrites} and set only
267
+ * after a sweep pass SUCCEEDS (including the nothing-held case) — a throwing
268
+ * sweep retries on the next growth edge instead of being lost for the lifetime.
269
+ * Reset on stop, so a stop()→start() cycle sweeps again (that second start is
270
+ * exactly a "removals from before this lifetime" case).
271
+ */
272
+ private reissuedHeldRevocations;
119
273
  /** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
120
274
  private pendingSelfPeerWrite;
121
275
  /** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
@@ -249,11 +403,11 @@ export declare class CadreNode implements SAppIdLookup {
249
403
  * before any network bring-up. Resolution order:
250
404
  *
251
405
  * 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.
406
+ * 2. `keyStore` set ⇒ {@link loadOrCreateIdentityKey} against `identityKeyId`
407
+ * (default {@link DEFAULT_IDENTITY_KEY_ID}): load when the slot is present,
408
+ * generate + persist when it is empty. A rejected `get` (access denied /
409
+ * backend failure) PROPAGATES — we never generate a new key on a read error,
410
+ * which would silently orphan the real identity.
257
411
  * 3. `privateKey` set ⇒ use it directly.
258
412
  * 4. Neither ⇒ leave undefined; libp2p generates an ephemeral key.
259
413
  *
@@ -261,7 +415,227 @@ export declare class CadreNode implements SAppIdLookup {
261
415
  * resolved key rather than regenerating or re-persisting.
262
416
  */
263
417
  private resolveIdentityKey;
418
+ /**
419
+ * Construct (or adopt) the node-local trusted-owner anchor and seed the
420
+ * out-of-band pinned keys from `config.trustedOwners`. The store is NEVER
421
+ * sourced from the replicated control DB — its entries come only from
422
+ * genesis self-trust ({@link initializeSeedBootstrap}), config pins (here),
423
+ * or runtime enrollment pins ({@link trustOwnerKeys}).
424
+ *
425
+ * Idempotent across stop()→start(): the store instance is kept, and
426
+ * re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
427
+ * idempotent). An injected store scoped to a different party is a
428
+ * configuration error (fail closed before any network bring-up).
429
+ */
430
+ private initializeTrustedOwnerStore;
431
+ /**
432
+ * Construct (or adopt) the node-local cold-start bootstrap-peer store (see
433
+ * {@link bootstrapPeerStore}). Synchronous: an injected store has already
434
+ * loaded its persisted targets by the time it is handed in (its `open` is the
435
+ * async part), and the in-memory fallback has nothing to load.
436
+ *
437
+ * Idempotent across stop()→start(): the store instance is kept, so retained
438
+ * targets survive a restart of the same node instance even with the ephemeral
439
+ * default. An injected store scoped to a different party is a configuration
440
+ * error (fail closed before any network bring-up) — a foreign party's addresses
441
+ * must never enter this node's dial loop.
442
+ */
443
+ private initializeBootstrapPeerStore;
444
+ /**
445
+ * The node-local cold-start bootstrap-peer store (null before {@link start}) —
446
+ * the retained owner addresses {@link reconcileControlCohort}'s cold-start
447
+ * branch re-dials. Exposed for diagnostics and for a host that wants to show
448
+ * "what would this node dial if it is stranded?".
449
+ */
450
+ getBootstrapPeerStore(): BootstrapPeerStore | null;
451
+ /**
452
+ * The node-local trusted-owner anchor (null before {@link start}). This is
453
+ * the set the authorized-membership predicate and seed-trust anchor consult —
454
+ * never the replicated `OwnerKey` table, which any stranger can pollute.
455
+ */
456
+ getTrustedOwnerStore(): TrustedOwnerStore | null;
457
+ /**
458
+ * Persist out-of-band-established owner keys into the node-local anchor —
459
+ * the runtime enrollment seam: call with `CadreInvite.ownerKeys` when
460
+ * redeeming an invite (BEFORE the first `applySeed`, so the anchor already
461
+ * holds the pins when seed trust consults it), or with an operator-supplied
462
+ * pin. Idempotent. ('genesis' provenance is reserved for the node's own
463
+ * founding key, seeded internally by {@link initializeSeedBootstrap}.)
464
+ *
465
+ * Validates every key's shape before trusting any of them (all-or-nothing):
466
+ * a malformed entry anywhere in `keys` rejects the whole call before a
467
+ * single key is anchored. For the invite route this means a `CadreInvite`
468
+ * carrying one malformed `ownerKeys` entry fails the redemption outright —
469
+ * consistent with the anchor's existing whole-record-or-nothing policy for
470
+ * a corrupt persisted entry (see `trusted-owner-store.ts`'s
471
+ * `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
472
+ * and leaving the caller to notice a key went missing.
473
+ */
474
+ trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void>;
264
475
  private createControlNode;
476
+ /**
477
+ * Map this node's config onto the control network's libp2p node options.
478
+ *
479
+ * Split out of {@link createControlNode} purely so the mapping is assertable without
480
+ * standing up a real libp2p node — `packages/cadre-core/test/cadre-node-control-node-options.spec.ts`
481
+ * calls it on a bare `new CadreNode(config)`. Read nothing else into the split; the
482
+ * only caller in production is `createControlNode`.
483
+ */
484
+ private buildControlNodeOptions;
485
+ /**
486
+ * Decide whether an inbound CONTROL-network connection from `remotePeerId`
487
+ * should be admitted — the policy behind the connection gater wired in
488
+ * {@link createControlNode}. Deny only on a positive "unauthorized outsider
489
+ * while no stranger path is open" determination; everything ambiguous admits
490
+ * and defers to the fail-closed per-stream gates (see
491
+ * `membership-connection-gater.ts` for the layer's rationale and the
492
+ * stranger-open protocol allowlist).
493
+ *
494
+ * Admits when ANY of:
495
+ * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
496
+ * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
497
+ * configured bootstrap/relay infrastructure);
498
+ * 2. an enrollment window is open ({@link openEnrollmentWindow}, opened by
499
+ * {@link createInvite}) — the invitee dials in before it is authorized;
500
+ * 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
501
+ * announced it (over the strand-addr RPC) as the transport peerId of its
502
+ * own strand node, so a NAT'd member's strand node can hold a
503
+ * circuit-relay reservation here (see `delegate-admission.ts`).
504
+ * Connection only: the per-stream gate below never honors a grant;
505
+ * 4. the authorized-member set is empty — cold start: the rows that would
506
+ * authorize anyone arrive by replication over these very connections;
507
+ * 5. the peer IS an authorized member; or
508
+ * 6. an open invitation is OUTSTANDING — at least one unexpired,
509
+ * not-fully-consumed invitation this node minted or persisted (see
510
+ * `StrandSolicitationService.hasOutstandingInvitation`). A formation
511
+ * initiator is another party's peer by design and its token is only
512
+ * checkable inside the protocol, so the gate asks the coarser question
513
+ * "does this node expect a stranger at all?". Merely REGISTERING the
514
+ * responder ({@link initializeStrandSolicitation}) no longer suspends
515
+ * stranger denial — eager registration (as `reference-app-rn` does at
516
+ * bring-up) and `formStrand`'s lazy initialization both leave the gate
517
+ * armed, because neither mints an invitation.
518
+ *
519
+ * Ordering is semantically free (the checks are OR'd) but decides who pays:
520
+ * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
521
+ * already on the deny path reaches check 6's invitation lookup.
522
+ *
523
+ * Caveats of check 6, both self-healing:
524
+ * - the in-memory mint registry dies with the process, so after a restart
525
+ * only invitations persisted as `FormationInvite` rows still hold the
526
+ * exemption open (re-mint otherwise — same story as the enrollment window);
527
+ * - a peer holding a token whose `FormationInvite` row has not replicated to
528
+ * this node yet is denied even though the formation handler would have
529
+ * accepted it, exactly like the unreplicated-membership-row case below.
530
+ *
531
+ * NOTE: check 4/5 runs a control-DB read per inbound connection
532
+ * (`listAuthorizedMembers`); connections are rare and cadres small, and this
533
+ * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
534
+ * read is safe here — unlike the per-stream gate, which must consult the
535
+ * materialized {@link authorizedControlPeers} snapshot instead.
536
+ * NOTE: a sibling whose membership row has not yet replicated to
537
+ * this node is denied until the row converges (typically via the owner);
538
+ * either side's next outbound reconcile dial (outbound is never gated)
539
+ * re-establishes the link — self-healing, but visible as a transient deny.
540
+ */
541
+ private admitInboundControlConnection;
542
+ /**
543
+ * Hold the control-network inbound gate open for not-yet-authorized peers
544
+ * until `untilEpochMs` (extends, never shrinks, an already-open window).
545
+ * {@link createInvite} calls this automatically; a host running an
546
+ * out-of-band enrollment flow (e.g. accepting a phone whose invite this node
547
+ * never minted) can open it explicitly before the stranger dials in.
548
+ */
549
+ openEnrollmentWindow(untilEpochMs: number): void;
550
+ /**
551
+ * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
552
+ * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
553
+ * at the CONNECTION level (all a circuit-relay reservation needs) for
554
+ * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
555
+ * authorized-membership gate has passed; public so tests can drive the
556
+ * admission policy without a full strand launch. A re-announce for the same
557
+ * (announcer, strand) REPLACES the previous delegate rather than
558
+ * accumulating. Never honored by {@link authorizeInboundControlStream}.
559
+ */
560
+ grantDelegateAdmission(announcerPeerId: string, strandId: string, delegatePeerId: string): void;
561
+ /** Is `remotePeerId` covered by a live delegate admission grant? */
562
+ hasDelegateAdmission(remotePeerId: string): boolean;
563
+ /**
564
+ * The admission checks SHARED by the connection gate
565
+ * ({@link admitInboundControlConnection}) and the per-stream control-DB gate
566
+ * ({@link authorizeInboundControlStream}) — factored so the two layers cannot
567
+ * drift. All in-memory and cheap (the stream gate runs this per inbound
568
+ * stream). Returns true when the peer must be admitted BEFORE any membership
569
+ * source is consulted:
570
+ * - the node is not fully up (`start()` in progress / DB torn down) — both
571
+ * gates exist from libp2p bring-up, before the control DB does;
572
+ * - the trusted-owner anchor is absent or empty — an un-enrolled node has
573
+ * no basis to judge anyone and MUST accept its enrollment seed;
574
+ * - the peer is one of the configured control bootstrap/relay nodes —
575
+ * operator-configured infrastructure, not cadre members.
576
+ * False only means "no unconditional admit": the caller then judges the peer
577
+ * against its own membership source (a live DB read for the fail-open
578
+ * connection gate; the materialized snapshot for the fail-closed stream gate).
579
+ */
580
+ private admitControlPeerUnconditionally;
581
+ /**
582
+ * Per-stream authorization for the four Optimystic control-DB protocols
583
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`),
584
+ * wired as `authorizeInboundStream` in {@link createControlNode} — the
585
+ * fail-closed layer behind the fail-open connection gater. Control node
586
+ * ONLY: strand cohort nodes legitimately serve cross-party peers.
587
+ *
588
+ * The STRICT SUBSET of {@link admitInboundControlConnection}: the same
589
+ * "no basis to judge" admissions (shared via
590
+ * {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
591
+ * (enrollment window, outstanding open invitation, delegate admission
592
+ * grant). The first two exist so a stranger can reach `/sereus/seed/1.0.0`
593
+ * and `/sereus/formation/1.0.0` — neither is gated here, and admitting a
594
+ * stranger to `repo` during an enrollment window is exactly the hole this
595
+ * gate closes. A DELEGATE-admitted connection (a member's strand node
596
+ * holding a circuit-relay reservation, see `delegate-admission.ts`) is
597
+ * likewise exactly a case this gate must still refuse: the delegate gets
598
+ * the connection and never the control DB.
599
+ *
600
+ * Admits when ANY of:
601
+ * 1. a shared-baseline check admits (not running / no DB, empty anchor,
602
+ * configured bootstrap infra);
603
+ * 2. the materialized authorized set is empty — cold start, before the
604
+ * rows that would authorize anyone have replicated in;
605
+ * 3. the peer IS in the materialized authorized set.
606
+ *
607
+ * Deliberately SYNCHRONOUS and pure in-memory — see
608
+ * {@link authorizedControlPeers} for why a control-DB read here would
609
+ * deadlock into mutual denial. With a sync predicate the upstream deadline
610
+ * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
611
+ * default.
612
+ *
613
+ * NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
614
+ * key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
615
+ * siblings deny its control-DB streams once their own snapshot is non-empty —
616
+ * owner status is a KEY, not a peer id, so being the owner does not exempt it.
617
+ * Harmless today (a real deployment persists its identity; the ephemeral-owner
618
+ * case appears only in tests, which configure the owner as bootstrap infra —
619
+ * `push-wake-e2e.integration.ts` design note 3). If an ephemeral-identity node
620
+ * ever becomes a supported deployment, this gate needs a key-based admission.
621
+ */
622
+ private authorizeInboundControlStream;
623
+ /**
624
+ * Refresh {@link authorizedControlPeers} from the control DB, best-effort: a
625
+ * failed read keeps the previous snapshot (never clears it), so a transient
626
+ * DB error can neither flip the stream gate's cold-start carve-out back open
627
+ * nor drop a legitimate member mid-flight. Never rejects.
628
+ *
629
+ * The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
630
+ * so two reads can no longer settle out of order.
631
+ */
632
+ private refreshAuthorizedControlPeers;
633
+ /**
634
+ * PeerIds of the configured control bootstrap nodes (relay/bootstrap
635
+ * infrastructure — always admitted inbound). Parsed once, lazily; an
636
+ * address without a `/p2p/<id>` component contributes nothing.
637
+ */
638
+ private getBootstrapPeerIds;
265
639
  private createStrandQueryable;
266
640
  /**
267
641
  * Schedule the node's initial self-record publish + ongoing refresh shortly
@@ -280,7 +654,11 @@ export declare class CadreNode implements SAppIdLookup {
280
654
  * PeerId (the resolved node identity from `keyStore`/`config.privateKey`).
281
655
  * - If the row already exists: a self-signed UPDATE bumping `UpdatedAt`.
282
656
  * - If not, and the node is its own owner: an owner-signed INSERT that
283
- * also carries the self-signature.
657
+ * also carries the self-signature. That INSERT is idempotent, so if an owner
658
+ * {@link authorizePeer} of this node's own id seated the row inside the
659
+ * read-then-insert window it no-ops — the publish then falls through to the
660
+ * UPDATE path (re-reading and re-signing against the row that landed) rather
661
+ * than leaving the authorize's null `Sig` in place, and reports `refreshed`.
284
662
  * - Otherwise: logs and returns (a non-owner node with no row yet must
285
663
  * wait for an owner to insert it; it can then self-refresh).
286
664
  *
@@ -294,6 +672,13 @@ export declare class CadreNode implements SAppIdLookup {
294
672
  registerSelf(): Promise<SelfRegistrationOutcome>;
295
673
  /** The body of {@link registerSelf}; serialised by its single-flight guard. */
296
674
  private publishSelfRecord;
675
+ /**
676
+ * Sign this node's address record for publication, stamped strictly later than
677
+ * `existing` (the row it is about to replace, or null for a first insert) so a
678
+ * same-millisecond re-publish still satisfies the monotonic `UpdatedAt` rule the
679
+ * `CadrePeer.AuthorizedUpdate` self-branch enforces.
680
+ */
681
+ private signSelfRecord;
297
682
  /**
298
683
  * The ed25519 keypair (base64url) the node signs its own record with — the key
299
684
  * behind its libp2p PeerId. Sourced from the resolved {@link identityKey}
@@ -399,6 +784,65 @@ export declare class CadreNode implements SAppIdLookup {
399
784
  * parse/lookup failure — never throws.
400
785
  */
401
786
  private peerStoreAddrs;
787
+ /**
788
+ * Retain a just-applied seed's owner-flagged peers as cold-start bootstrap
789
+ * dial targets (see {@link bootstrapPeerStore}).
790
+ *
791
+ * Called for every seed this node accepts, on BOTH intake paths — the
792
+ * {@link applySeed} wrapper (which may run on a throwaway service, so the
793
+ * service itself is the wrong place to keep this) and the inbound
794
+ * `/sereus/seed/1.0.0` handler via `onSeedApplied`. Peers with no address are
795
+ * skipped: there is nothing to dial.
796
+ *
797
+ * `isOwner` is the seed's own claim about its peers, exactly as
798
+ * `SeedBootstrapService.applySeed` uses it to choose its dial targets. That is
799
+ * sound here for the same reason it is sound there — the whole seed is
800
+ * signature-checked against a trust-anchored signer before this runs, and the
801
+ * flag only *selects a dial target*; a dial grants no authority.
802
+ *
803
+ * Self is excluded. `createSeed` projects EVERY `CadrePeer` row, so an owner
804
+ * that applies a seed minted after it joined finds itself in the owner list;
805
+ * retaining that would make the cold-start pass dial this node forever (the
806
+ * steady-state pass filters self out of its sibling list for the same reason).
807
+ */
808
+ private recordSeedBootstrapPeers;
809
+ /**
810
+ * Cold-start branch of the reconcile pass: re-dial the seed's owner peers
811
+ * while the control database still has no siblings to dial.
812
+ *
813
+ * `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
814
+ * When that single dial fails — owner momentarily down, relay reservation not
815
+ * up yet, NAT traversal lost the race — the joining node has an empty
816
+ * `CadrePeer` table and no connection, and the steady-state pass above cannot
817
+ * help: it dials only siblings enumerated from that very table. Retrying here
818
+ * is the only way back in.
819
+ *
820
+ * Unbounded, at the reconcile cadence, with no backoff: the branch is already
821
+ * gated by "the control database has no siblings", so it stops the moment the
822
+ * node is actually in the party, and a node that is stranded MUST keep trying —
823
+ * a give-up rule would turn a transient outage into a permanent one. The steady
824
+ * cost is one dial per bootstrap peer per pass (seeds nominate one or a few
825
+ * owners), each failing fast against an unreachable address.
826
+ * NOTE: if seeds ever carry many owner peers, or the reconcile cadence tightens
827
+ * well below its 15 s default, add per-peer backoff here.
828
+ *
829
+ * Best-effort per peer, exactly like {@link dialControlSibling}: one dead
830
+ * address never aborts the pass.
831
+ */
832
+ private dialColdStartBootstrap;
833
+ /**
834
+ * Bind a bootstrap peer's seed addresses to its peer id, so the dial
835
+ * authenticates the peer it is aiming at rather than trusting whoever answers.
836
+ *
837
+ * An address that already carries `/p2p/<id>` must carry THIS peer's id or it
838
+ * is dropped (a seed that disagrees with itself is not a dial target); one
839
+ * that carries none — an `addressOverrides`-published record, say — gets the
840
+ * id encapsulated. Unencapsulatable addresses are dropped rather than dialed
841
+ * bare.
842
+ */
843
+ private bootstrapDialAddrs;
844
+ /** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
845
+ private dialBootstrapPeer;
402
846
  /**
403
847
  * Whether a control write happening now would commit local-only. A sound lower
404
848
  * bound is "no connected control peers": 0 connections ⇒ the block's cluster is
@@ -408,6 +852,9 @@ export declare class CadreNode implements SAppIdLookup {
408
852
  * is caught by the cohort's periodic pull-on-read instead. Re-issuing an already-
409
853
  * replicated row is harmless (an idempotent monotonic bump), so the coarse proxy
410
854
  * is acceptable as the agreed first cut.
855
+ *
856
+ * Reads {@link getControlConnectionCount} — the public, embedder-facing form of
857
+ * the same sample — so the two cannot drift.
411
858
  */
412
859
  private committedAlone;
413
860
  /**
@@ -417,12 +864,23 @@ export declare class CadreNode implements SAppIdLookup {
417
864
  * cohort grows; otherwise the write replicated and any stale queue entry for this
418
865
  * subject is dropped.
419
866
  *
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}).
867
+ * A `remove` never queues here — a delete's re-replicable half is its
868
+ * `Revocation` tombstone, which the committed-delete seam routes into
869
+ * {@link pendingRevocations} ({@link noteGuardedDelete}). The `remove` arm only
870
+ * drops any queued authorize for the subject (the row is gone; re-issuing the
871
+ * insert would resurrect it) and, when the commit was alone, logs the
872
+ * security-relevant window loudly.
424
873
  */
425
874
  private noteControlWrite;
875
+ /**
876
+ * Committed-delete seam handler ({@link GuardedDeleteListener}, wired in
877
+ * {@link start}): a guarded-table delete and its `Revocation` tombstone
878
+ * committed. If the node was alone the tombstone is local-only — queue it for
879
+ * an owner-signed re-issue on cohort growth; otherwise it broadcast, so drop
880
+ * any stale queue entry for the stamp. Synchronous by contract — bookkeeping
881
+ * only, never throws into the delete path.
882
+ */
883
+ private noteGuardedDelete;
426
884
  /**
427
885
  * Drain the write-while-alone re-replication queue: re-issue the writes that
428
886
  * committed local-only now that the cohort can broadcast them. Public so the
@@ -443,6 +901,38 @@ export declare class CadreNode implements SAppIdLookup {
443
901
  * the drain.
444
902
  */
445
903
  private retouchSelfDeviceToken;
904
+ /**
905
+ * Re-issue `Revocation` tombstones so a removal that committed while alone
906
+ * still reaches the cohort. Two regimes, one method:
907
+ *
908
+ * - **First successful pass per process** ({@link reissuedHeldRevocations}
909
+ * false): sweep EVERY locally-held tombstone from `queryRevocations()` — the
910
+ * only cover for a removal made before this process started, since the
911
+ * in-memory queue does not survive a restart. Queued stamps are part of that
912
+ * same batch (exactly once — one transaction, no double bump).
913
+ * - **Later passes**: only the tombstones queued in-session by
914
+ * {@link noteGuardedDelete}.
915
+ *
916
+ * Owner-gated: a node with no owner key cannot sign a re-issue, so stray queue
917
+ * entries are dropped (mirrors {@link drainPendingPeerWrites}). Best-effort — a
918
+ * failure is logged and leaves the queue (and the sweep flag) untouched for the
919
+ * next growth edge. A successful `reissueRevocations` exec is NOT proof of
920
+ * broadcast (the connection that fired the growth edge may not be in the
921
+ * affected block's cluster — see
922
+ * tickets/backlog/control-rereplication-broadcast-confirmation); the drain
923
+ * inherits that known gap, and a full disconnect→reconnect (or the next
924
+ * process's sweep) re-covers it.
925
+ *
926
+ * NOTE: the sweep is O(all tombstones ever) row-updates — plus one owner
927
+ * signature each — in one transaction, once per lifetime. `Revocation` is
928
+ * append-only and unbounded growth is declared acceptable for a cadre-sized
929
+ * party. If the tombstone table ever gets large, bound the sweep (e.g. persist
930
+ * a node-local high-water mark of what has been re-issued while connected)
931
+ * instead of re-touching everything; note the `Math.max(...)` spread below also
932
+ * caps out around 10^5 rows (V8 argument limit) before the size becomes merely
933
+ * a latency problem.
934
+ */
935
+ private drainPendingRevocations;
446
936
  /**
447
937
  * One-shot, first-cohort-growth reconstruction of the write-while-alone queue for
448
938
  * an OWNER node: re-touch every membership row that may be an unreplicated
@@ -455,29 +945,36 @@ export declare class CadreNode implements SAppIdLookup {
455
945
  * row is a no-op-equivalent).
456
946
  *
457
947
  * 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}.
948
+ * rows for other peers, and rows it merely holds are not its to re-issue.
949
+ * DELETEs are not reconstructed here — the `Revocation` tombstone sweep in
950
+ * {@link drainPendingRevocations} covers them (a removed row leaves no
951
+ * `CadrePeer` trace, but its tombstone is re-issuable).
461
952
  */
462
953
  private reconstructAuthoredMembership;
954
+ /**
955
+ * Body of {@link reconstructAuthoredMembership}'s sweep: re-issue each candidate
956
+ * owner-authored row, returning how many were re-touched. A per-row failure is
957
+ * logged and skipped; a teardown mid-sweep stops it.
958
+ */
959
+ private reissueAuthoredMembershipRows;
463
960
  /**
464
961
  * 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}.
962
+ * authorize as an idempotent monotonic owner UPDATE
963
+ * ({@link reissuePeerAuthorize}) now that the cohort can broadcast it.
964
+ * Sequential (no fan-out) to avoid a thundering re-touch; an entry is cleared
965
+ * only on success, so a failure (or a still-alone re-commit) leaves it queued
966
+ * for the next growth. An entry is skipped (and cleared) if the row vanished
967
+ * (raced a delete) or now carries a self-`Sig` (the peer self-published and owns
968
+ * its republish). Removals are drained by {@link drainPendingRevocations}, not
969
+ * here.
479
970
  */
480
971
  private drainPendingPeerWrites;
972
+ /**
973
+ * Body of {@link drainPendingPeerWrites}' loop. An entry is cleared only on
974
+ * success (or when there is nothing left to re-issue); a failure is logged and
975
+ * the entry stays queued for the next cohort growth.
976
+ */
977
+ private reissuePendingPeerWrites;
481
978
  /**
482
979
  * Re-issue an owner membership row as a monotonic owner UPDATE: bump
483
980
  * `UpdatedAt` strictly above the stored value (and the wall clock) and re-sign via
@@ -549,8 +1046,10 @@ export declare class CadreNode implements SAppIdLookup {
549
1046
  * 2. `publicKey <-> peerId` binding — the stored key's libp2p identity is the
550
1047
  * requested peerId,
551
1048
  * 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:
1049
+ * 4. the row's `StampId` is NOT retired in `CadreControl.Revocation` — the
1050
+ * read-side half of the clear (see below),
1051
+ * 5. self-signature verifies against the bound `CadrePeer.PublicKey`,
1052
+ * 6. freshness — `updatedAt` is positive and within `opts.maxAgeMs` (default:
554
1053
  * no ceiling, since a push token is valid until it rotates).
555
1054
  *
556
1055
  * A peer that is not a current member, or whose token has no backing `CadrePeer`
@@ -672,6 +1171,14 @@ export declare class CadreNode implements SAppIdLookup {
672
1171
  /**
673
1172
  * Add a strand with its sApp configuration.
674
1173
  * The hosting application must provide the sApp schema when creating a strand.
1174
+ *
1175
+ * A rejected call leaves nothing running but DOES leave the sApp config
1176
+ * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1177
+ * automatic relaunch need it. So once the strand's row is visible on the control
1178
+ * network (via {@link publishStrand} or another member), a failed launch keeps
1179
+ * being re-attempted in the background — each failure re-emitting `strand:error`
1180
+ * — until it succeeds. {@link detachStrand} (reached via {@link stopStrand}) is
1181
+ * what abandons a strand for good.
675
1182
  */
676
1183
  addStrand(config: StrandConfig): Promise<StrandInstance>;
677
1184
  /**
@@ -689,7 +1196,7 @@ export declare class CadreNode implements SAppIdLookup {
689
1196
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
690
1197
  * so peer identity and owner key are one and the same. That key must be
691
1198
  * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
692
- * at genesis) or the schema's `Strand.Authorized` constraint rejects the write.
1199
+ * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
693
1200
  * Failing loudly here is intentional: a silently-unpublished strand would run
694
1201
  * as a local-only island that no peer could ever discover or join.
695
1202
  *
@@ -697,14 +1204,70 @@ export declare class CadreNode implements SAppIdLookup {
697
1204
  * {@link addStrand}).
698
1205
  * @param type - `'o'` for open (default) or `'c'` for closed.
699
1206
  * @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.
1207
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1208
+ * the control DB rejects the (unauthorized) insert.
702
1209
  */
703
1210
  publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<void>;
1211
+ /**
1212
+ * Remove this party's `Strand` row from the shared control database — the owner-signed
1213
+ * inverse of {@link publishStrand}.
1214
+ *
1215
+ * Party-wide, unlike {@link stopStrand}, which only stops the strand on THIS node: every
1216
+ * cadre node watching the table sees the row vanish on its next poll (default 5 s) and
1217
+ * stops its own instance. This node converges immediately — the method forces a watcher
1218
+ * poll and stops any still-running local instance before resolving. Removing the row
1219
+ * removes OUR party's participation only: other parties in the strand keep their own
1220
+ * rows and the strand network carries on, so no cross-party sign-off is involved — this
1221
+ * is an owner-signed control-plane write like every other, not "destroy the network".
1222
+ *
1223
+ * Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
1224
+ * this party's membership secret for that network, and it is stored nowhere else.
1225
+ * With it gone the party can never again admit a member to that closed strand, and a
1226
+ * re-published row would carry a DIFFERENT key that does not match the membership
1227
+ * already written into the strand's RBAC layer. The strand id itself is NOT
1228
+ * blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
1229
+ * stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
1230
+ * `Revocation` tombstone names the id, which the consent branch of
1231
+ * `Strand.AuthorizedInsert` refuses ever after). Outstanding `FormationInvite` rows
1232
+ * bound to the removed strand are unredeemable while the row is absent (the formation
1233
+ * recorder resolves the strand as missing and rejects cleanly).
1234
+ *
1235
+ * Convergence caveats, same class as {@link enrollValidationKey}'s: a sibling that has
1236
+ * not yet synced keeps running the strand until its own watcher observes the missing
1237
+ * row; a sibling whose `strandFilter` never admitted this strand never observes the
1238
+ * removal AT ALL and keeps running its instance indefinitely — opting out of watching a
1239
+ * strand is also opting out of its party-wide removal, so such a node's only stop is its
1240
+ * own local {@link stopStrand}/`unpublishStrand` call; and a removal committed while
1241
+ * ALONE (0 control connections) deletes the row local-only. The accompanying
1242
+ * `Revocation` tombstone IS queued and re-issued on the next cohort-growth edge
1243
+ * ({@link noteGuardedDelete}/{@link drainPendingRevocations}), so the stamp retirement
1244
+ * — and with it the consent re-seat foreclosure — does propagate; but the physical row
1245
+ * deletion cannot be replayed, and `queryStrands` reads raw (no retired-stamp filter),
1246
+ * so siblings that already hold the row keep running the strand until the collection
1247
+ * itself converges (logged loudly; see the delete-while-alone durability note in
1248
+ * docs/architecture.md).
1249
+ *
1250
+ * A no-op (no throw, no tombstone) when the row is already absent — but a
1251
+ * locally-running instance of that id is still stopped.
1252
+ *
1253
+ * NOTE: control-plane only — the strand's local durable storage is retained. Stopping the
1254
+ * instance closes the `StrandDatabase` and its libp2p node but purges no blocks, so a
1255
+ * closed strand's content stays readable on disk to anyone with the data directory. If a
1256
+ * caller ever needs removal to mean "and erase the local copy", that is a separate purge
1257
+ * step, not a widening of this method.
1258
+ *
1259
+ * @param strandId - The `Strand` row id to remove (as passed to {@link publishStrand}).
1260
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1261
+ * the signer is not an enrolled owner (the schema's `Strand.AuthorizedDelete` rejects
1262
+ * the write and the row survives). A rejection does NOT imply the row survived: the
1263
+ * local stop runs after the control-plane delete has already committed, so a failure
1264
+ * there throws over a completed removal.
1265
+ */
1266
+ unpublishStrand(strandId: string): Promise<void>;
704
1267
  /**
705
1268
  * Publish an owner-signed `FormationInvite` (open-invitation token) to the
706
1269
  * shared control database, so a later {@link formStrand} redemption can be
707
- * validated against it (the consent branch of `Strand.Authorized`).
1270
+ * validated against it (the consent branch of `Strand.AuthorizedInsert`).
708
1271
  *
709
1272
  * Counterpart to {@link createOpenInvitation}, which only mints the
710
1273
  * out-of-band {@link OpenInvitation} envelope: persisting the matching
@@ -729,11 +1292,81 @@ export declare class CadreNode implements SAppIdLookup {
729
1292
  validationUrl?: string;
730
1293
  strandId?: string;
731
1294
  }): Promise<void>;
1295
+ /**
1296
+ * Enroll an approver public key allowed to sign off on `ValidationUrl` redemptions.
1297
+ *
1298
+ * A `FormationInvite` carrying a `ValidationUrl` is only redeemable when the approval
1299
+ * that comes back from that URL is signed by a key present in `CadreControl.ValidationKey`
1300
+ * — this is how a party says which outside approver it trusts. Without an enrollment,
1301
+ * every such invitation is unredeemable.
1302
+ *
1303
+ * Signs with the same self-owner key as {@link publishStrand} (the ed25519 key behind
1304
+ * this node's PeerId, which must be an enrolled `OwnerKey`). Throws loudly if the node
1305
+ * isn't started, exposes no signing key, or the key is blank — a blank key would reach
1306
+ * the database and fail the signature CHECK as an opaque constraint error.
1307
+ *
1308
+ * Enrolling this node's OWN owner key is permitted and harmless: the domain/action tags
1309
+ * baked into each authorization digest mean an owner-key signature can never satisfy the
1310
+ * approval rule and an approval can never satisfy an owner rule. No guard is needed.
1311
+ *
1312
+ * NOTE: enrollment is replicated control state, so a key enrolled here is visible to a
1313
+ * sibling cadre node only once control replication converges. A redemption that arrives
1314
+ * at a node which has not caught up is refused as not-enrolled — the same convergence
1315
+ * gap the schema records on `Strand.StampId`. Enroll before circulating the invitations
1316
+ * that depend on the key.
1317
+ *
1318
+ * @param key - Approver public key to enroll (base64url ed25519 public key).
1319
+ */
1320
+ enrollValidationKey(key: string): Promise<void>;
1321
+ /**
1322
+ * Remove an approver public key.
1323
+ *
1324
+ * Narrows who may approve FUTURE redemptions ONLY. The schema's approval CHECKs run at
1325
+ * write time, so a join that was already approved by this key stays valid — removal is
1326
+ * not retroactive and does not re-examine committed rows.
1327
+ *
1328
+ * Rotation is therefore add-then-remove, in that order: removing the only enrolled key
1329
+ * while `ValidationUrl` invitations are outstanding makes every one of them unredeemable
1330
+ * until a new key is enrolled.
1331
+ *
1332
+ * Removing a key that is not enrolled is a silent no-op (no throw, no `Revocation`
1333
+ * tombstone) — see {@link ControlDatabase.deleteValidationKey}.
1334
+ *
1335
+ * @param key - Approver public key to remove (base64url ed25519 public key).
1336
+ */
1337
+ removeValidationKey(key: string): Promise<void>;
1338
+ /**
1339
+ * The approver keys currently enrolled, sorted. Read-only, so unlike
1340
+ * {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner signing
1341
+ * key — only a started node.
1342
+ */
1343
+ listValidationKeys(): Promise<string[]>;
1344
+ /**
1345
+ * Resolve the owner keypair every owner-signed control write needs — {@link publishStrand},
1346
+ * {@link unpublishStrand}, {@link publishFormationInvite}, {@link enrollValidationKey},
1347
+ * {@link removeValidationKey}
1348
+ * — failing loudly in one two-part shape (not started / no signing key, naming owner
1349
+ * genesis as the fix). Narrows `controlDatabase` for the caller: a non-null return means
1350
+ * `this.controlDatabase` is non-null too.
1351
+ *
1352
+ * @param action - Infinitive phrase naming the attempted write, e.g. `'enroll a
1353
+ * validation key'`; interpolated into both messages.
1354
+ */
1355
+ private requireOwnerSigningKey;
732
1356
  /**
733
1357
  * Shared strand launch path for both the explicit (`addStrand`) and the
734
1358
  * control-discovered (`handleStrandAdded`) entry points. Resolves the cohort
735
1359
  * seed, selects the mode, starts the strand, and registers it with the
736
1360
  * hibernation manager before emitting `strand:started`.
1361
+ *
1362
+ * Idempotent no-op when the strand manager already tracks `strand.Id` — the
1363
+ * ordinary founding sequence is `addStrand` (starts it locally) followed by
1364
+ * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
1365
+ * rediscovers a row it already started and calls this again via
1366
+ * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
1367
+ * (already-connected) cohort seed and re-emit `strand:started` for an
1368
+ * instance that never stopped. The guard runs before the cohort-seed RPC
1369
+ * fan-out, so a rediscovery costs nothing beyond the map lookup.
737
1370
  */
738
1371
  private launchStrand;
739
1372
  /**
@@ -749,8 +1382,61 @@ export declare class CadreNode implements SAppIdLookup {
749
1382
  * empty (`[]`) — mode still follows membership, and the empty seed self-heals
750
1383
  * on the next resume / check-in pass. Returns an empty seed when the control DB
751
1384
  * or node is absent (not yet started / torn down).
1385
+ *
1386
+ * When `delegatePeerId` is given (a strand launch/resume is imminent), the
1387
+ * pass doubles as the delegate ANNOUNCEMENT: our circuit relays are merged
1388
+ * into the RPC targets and every request carries the delegate peerId, so each
1389
+ * receiver records an admission grant BEFORE `libp2p.start()` (re-)dials the
1390
+ * relay reservation — the gate denial there is fatal, not degraded. A
1391
+ * party-member relay answers the RPC (its control node admits us as a
1392
+ * member); a dedicated ops/ relay does not speak the protocol and the
1393
+ * per-peer failure folds to `[]` — harmless, it has no membership gate and
1394
+ * needs no grant. The relay's direct addr rides along as the dial fallback
1395
+ * for a relay we are not yet connected to.
752
1396
  */
753
1397
  private resolveCohortSeed;
1398
+ /**
1399
+ * The circuit relays this node's strand nodes would reserve through: the
1400
+ * union of the RESOLVED listen addrs' circuits — a hand-written
1401
+ * `network.listenAddrs` circuit entry or a `network.relayAddrs` entry, which
1402
+ * the strand node inherits verbatim either way — and the control node's own
1403
+ * live `/p2p-circuit` multiaddrs (a reservation this node discovered rather
1404
+ * than configured).
1405
+ *
1406
+ * NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
1407
+ * source) gets no announcement, and a membership-gated one will deny it; fine
1408
+ * now, every realistic topology feeds one of the two sources.
1409
+ */
1410
+ private circuitRelayTargets;
1411
+ /**
1412
+ * Record the announce timestamps {@link refreshDelegateGrants} throttles on,
1413
+ * for every relay a delegate-carrying announce pass dialed.
1414
+ *
1415
+ * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
1416
+ * per-peer failure to `[]` and reports no per-peer success, and threading
1417
+ * success out would change its API for little gain. A failed INITIAL announce
1418
+ * is fatal-at-start anyway (the relay denies the reservation,
1419
+ * `libp2p.start()` throws, and wake/check-in re-resume retries with a fresh
1420
+ * announce); a failed REFRESH retries within `DELEGATE_GRANT_TTL_MS / 2`
1421
+ * (15 min), still inside the 30 min TTL.
1422
+ */
1423
+ private recordDelegateAnnounces;
1424
+ /**
1425
+ * Re-announce every running strand's delegate peerId to this node's circuit
1426
+ * relays, throttled to once per `DELEGATE_GRANT_TTL_MS / 2` per
1427
+ * (relay, strand). A grant must outlive the reservation: a dropped relay
1428
+ * connection makes the strand's circuit-relay transport re-dial and face the
1429
+ * connection gate again, so the relay must still hold a live grant then.
1430
+ * RELAY targets only — siblings get their announce on every launch/resume
1431
+ * seed pass, and a grant only matters where a reservation can be re-dialed.
1432
+ *
1433
+ * Strands announce CONCURRENTLY: this runs ahead of the reconcile pass's
1434
+ * sibling enumeration, and one unreachable relay costs a dial timeout per
1435
+ * target, which must not stack up per strand.
1436
+ */
1437
+ private refreshDelegateGrants;
1438
+ /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
1439
+ private announceDelegateToDueRelays;
754
1440
  /**
755
1441
  * The local strand instance's dialable strand-network multiaddrs for the
756
1442
  * strand-addr RPC, ordered signaling-first (reusing the control node's
@@ -762,9 +1448,26 @@ export declare class CadreNode implements SAppIdLookup {
762
1448
  */
763
1449
  private getStrandMultiaddrs;
764
1450
  /**
765
- * Remove a strand
1451
+ * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
1452
+ * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
1453
+ * intact, so on the next node restart (or watcher poll) the strand is rediscovered and
1454
+ * surfaces as `strand:discovered` again. Party-wide removal is {@link unpublishStrand}.
1455
+ */
1456
+ stopStrand(strandId: string): Promise<void>;
1457
+ /**
1458
+ * Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
1459
+ * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config, stop
1460
+ * the instance, emit `strand:stopped`. Touches no control-plane row — which side of the
1461
+ * removal the node is on is the caller's concern, not this method's.
1462
+ *
1463
+ * The stop + emit are skipped when the strand manager holds no instance for `strandId` —
1464
+ * e.g. a party owner that published a strand's row but never ran it locally, or an
1465
+ * explicit {@link stopStrand} for an id this node never started. `hibernationManager`
1466
+ * untrack and the `sAppConfigs` delete stay unconditional (both are no-ops when there is
1467
+ * nothing to remove), so a launch that failed before an instance was ever tracked still
1468
+ * gets its stray sApp config cleared.
766
1469
  */
767
- removeStrand(strandId: string): Promise<void>;
1470
+ private detachStrand;
768
1471
  /**
769
1472
  * Record activity on a strand (resets hibernation timer).
770
1473
  *
@@ -841,10 +1544,58 @@ export declare class CadreNode implements SAppIdLookup {
841
1544
  * Get the control network node (for advanced use)
842
1545
  */
843
1546
  getControlNode(): Libp2p | null;
1547
+ /**
1548
+ * Start keeping a relay reservation: dial the given relay(s) from the control
1549
+ * node, ask the first one that answers for a reservation slot, wait until the
1550
+ * resulting `/p2p-circuit` address makes this node dialable — and keep a
1551
+ * supervisor running that re-drives whenever the reservation is later lost.
1552
+ *
1553
+ * For nodes that listen on the bare `/p2p-circuit` SEARCH addr (a browser tab)
1554
+ * rather than a configured `network.relayAddrs` circuit listener — see
1555
+ * `relay-reservation.ts` for why the two are alternatives and why configuring
1556
+ * both is a regression. Opt-in: nothing calls this for the CLI/host nodes.
1557
+ *
1558
+ * Resolves once the FIRST attempt has settled, exactly as it did when it drove
1559
+ * once; the retries continue in the background until {@link stop} or the next
1560
+ * `reserveRelays` call. Fail-soft — never throws. An unreachable relay, a
1561
+ * missing control node, or a timeout all resolve to a non-`reserved` status, so
1562
+ * a caller can await this during startup without a dead relay aborting the node.
1563
+ *
1564
+ * Passing an empty list stops the supervisor and resets the posture to `none`
1565
+ * without dialing or waiting.
1566
+ */
1567
+ reserveRelays(addrs: string[], opts?: RelayReservationSupervisorOptions): Promise<RelayReservationState>;
1568
+ /**
1569
+ * The node's CURRENT relay-reservation posture, recomputed from the control
1570
+ * node's live multiaddrs on every call.
1571
+ *
1572
+ * Deliberately not memoised: a reservation can be lost after
1573
+ * {@link reserveRelays} succeeds (the relay restarts, the connection drops) and
1574
+ * a cached `reserved` would let a caller mint invitations carrying circuit
1575
+ * addresses that no longer route.
1576
+ *
1577
+ * A lost reservation now recovers on its own — the supervisor {@link reserveRelays}
1578
+ * starts re-drives on a backoff, reporting `retrying` meanwhile. `error` here
1579
+ * means nothing is going to try again: no supervisor, or no control node.
1580
+ */
1581
+ getRelayReservationState(): RelayReservationState;
844
1582
  /**
845
1583
  * Get the control database (for advanced queries)
846
1584
  */
847
1585
  getControlDatabase(): ControlDatabase | null;
1586
+ /**
1587
+ * Open control-network connections right now. A lower-bound proxy for replication
1588
+ * reach: 0 connections ⇒ a control write commits local-only. The private
1589
+ * `committedAlone` write-while-alone seam is defined in terms of this.
1590
+ *
1591
+ * It approximates the precise signal (the block's cluster size), and a caller that
1592
+ * samples it AFTER a write samples a slightly wider window than `committedAlone`
1593
+ * does inside the write itself. It exists so an embedder can warn an owner before a
1594
+ * control write that this machine currently sees none of its siblings, and report
1595
+ * after one that the write may not have travelled — a warning shown unconditionally
1596
+ * is a warning people learn to ignore.
1597
+ */
1598
+ getControlConnectionCount(): number;
848
1599
  /**
849
1600
  * Force a poll of the strand watcher (for testing)
850
1601
  */
@@ -872,7 +1623,10 @@ export declare class CadreNode implements SAppIdLookup {
872
1623
  */
873
1624
  private resolveInviteAddresses;
874
1625
  /**
875
- * Enumerate the cadre's `CadrePeer` membership.
1626
+ * Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
1627
+ * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
1628
+ * never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
1629
+ * revoked peer is not addressable either.
876
1630
  */
877
1631
  listMembers(): Promise<Array<{
878
1632
  peerId: string;
@@ -883,30 +1637,72 @@ export declare class CadreNode implements SAppIdLookup {
883
1637
  *
884
1638
  * This is the ADDRESSABLE surface ("do I have a dialable address record for this
885
1639
  * 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}.
1640
+ * push fan-out use this. A peer whose stamp is retired in `CadreControl.Revocation`
1641
+ * is excluded here too (the filter lives in
1642
+ * {@link ControlDatabase.queryCadrePeers}): revocation removes a peer from the
1643
+ * addressable surface, not only the trust-facing one, so a revoked peer is no
1644
+ * longer dialed, RPC'd, or handed out as an address. The trust-facing gate is
1645
+ * {@link isAuthorizedMember}.
887
1646
  */
888
1647
  isMember(peerId: string): Promise<boolean>;
889
1648
  /**
890
1649
  * 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
1650
+ * the addressable set ({@link listMembers}). A peer is authorized iff ALL hold:
1651
+ *
1652
+ * 1. it is not this node itself — a node publishes its own `CadrePeer` address
1653
+ * row so its dialable address rides in seeds, but "self" is not a peer this
1654
+ * node authorized;
1655
+ * 2. its `CadrePeer` row carries a complete voucher (`StampId`, `VouchOwner`,
1656
+ * `VouchSig` all non-null);
1657
+ * 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
1658
+ * ({@link getTrustedOwnerStore}) — never the replicated `OwnerKey` table,
1659
+ * which any stranger can genesis-pollute; and
1660
+ * 4. `VouchSig` verifies as that owner's signature over the row's voucher
1661
+ * digest ({@link verifyCadrePeerVoucher}), so the anchored owner really
1662
+ * vouched THIS peer id under THIS row's nonce; and
1663
+ * 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
1664
+ * upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
1665
+ * before ANY reader (this predicate included) sees them, so a row resurrected
1666
+ * by replaying the captured admission approval on a node that had not yet
1667
+ * converged on the tombstone (the write-time `NotRevoked` CHECK only sees
1668
+ * local rows) is still inert to every reader that has the tombstone.
1669
+ *
1670
+ * Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
1671
+ * not-yet-enrolled node authorizes no one), a null/partial voucher, an
1672
+ * unanchored `VouchOwner`, or a bad signature all yield "not authorized" —
1673
+ * having an address row is NOT membership. The control-network wake and
894
1674
  * strand-address gates consult this set, NOT the addressable one.
895
1675
  *
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.
1676
+ * NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
1677
+ * in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
1678
+ * legit member goes un-authorized on readers that only pin the new key. Full
1679
+ * rotation handling (re-vouch on rotate) is the
1680
+ * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
901
1681
  */
902
1682
  listAuthorizedMembers(): Promise<Array<{
903
1683
  peerId: string;
904
1684
  multiaddr: string | null;
905
1685
  }>>;
1686
+ /**
1687
+ * Checks 2–4 of the authorized-membership predicate (see
1688
+ * {@link listAuthorizedMembers}) for one `CadrePeer` row: complete voucher,
1689
+ * `VouchOwner` in the node-local anchor, signature valid over the row's
1690
+ * (PeerId, StampId) voucher digest.
1691
+ *
1692
+ * NOTE: verifies the ed25519 signature on every call (no memo of already-
1693
+ * verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
1694
+ * devices and the gates run per inbound request, so this is cheap today; if
1695
+ * membership or gate traffic ever grows, cache verified triples keyed on the
1696
+ * row's `StampId` (which rotates with every re-vouch).
1697
+ */
1698
+ private hasAnchoredVoucher;
906
1699
  /**
907
1700
  * Probe whether a given peer is an AUTHORIZED party member (see
908
1701
  * {@link listAuthorizedMembers}) — the gate the control-network wake and
909
- * strand-address responders consult, NOT {@link isMember} (the addressable surface).
1702
+ * strand-address responders consult, NOT {@link isMember} (the addressable
1703
+ * surface). Deliberately scans the full membership (one `CadrePeer` query)
1704
+ * rather than adding a single-row read path: cadres are small, and one code
1705
+ * path keeps the predicate impossible to drift from the list.
910
1706
  */
911
1707
  isAuthorizedMember(peerId: string): Promise<boolean>;
912
1708
  /**
@@ -935,6 +1731,68 @@ export declare class CadreNode implements SAppIdLookup {
935
1731
  * Get the seed bootstrap service (for advanced use)
936
1732
  */
937
1733
  getSeedBootstrapService(): SeedBootstrapService | null;
1734
+ /**
1735
+ * The event callbacks every {@link SeedBootstrapService} this node owns is
1736
+ * wired with — shared by the owner-capable service ({@link initializeSeedBootstrap})
1737
+ * and the listener-only one ({@link enableSeedListener}) so the two cannot drift.
1738
+ *
1739
+ * A seed applied by the INBOUND protocol handler writes no `CadrePeer` row of
1740
+ * its own (it merges the libp2p peer store and dials owners), so the automatic
1741
+ * write-driven refresh never fires for it — yet applying it can ANCHOR a new
1742
+ * owner key, which flips rows already present from unauthorized to authorized.
1743
+ * Hence the explicit refresh here; without it a peer the freshly anchored owner
1744
+ * vouched for stays denied until the next timed cohort reconcile.
1745
+ */
1746
+ private seedEventCallbacks;
1747
+ /**
1748
+ * Re-materialize the authorized-peer snapshot that the fail-closed per-stream
1749
+ * control-DB gate ({@link authorizeInboundControlStream}) judges against.
1750
+ *
1751
+ * NO `CadrePeer` writer needs to call this: the control database notifies it
1752
+ * after every committed member-row write (`ControlDatabase.mutateCadrePeer`,
1753
+ * wired in {@link start}), which is exactly what makes the refresh automatic
1754
+ * rather than a caller obligation. It stays public for the changes that write
1755
+ * NO row locally and so raise no notification:
1756
+ *
1757
+ * - a membership row that arrived by REPLICATION (otherwise picked up on the
1758
+ * next timed cohort reconcile — bounded staleness by design), and
1759
+ * - a newly anchored trusted owner key ({@link applySeed}), which flips rows
1760
+ * ALREADY present from unauthorized to authorized without touching them.
1761
+ *
1762
+ * Coalescing: marks the snapshot stale and resolves once a refresh that began
1763
+ * after this call has completed, so a caller that awaits it always observes
1764
+ * its own change. Concurrent callers share one read. Idempotent and
1765
+ * best-effort (a failed read keeps the previous snapshot); never rejects.
1766
+ */
1767
+ refreshMembershipGate(reason?: string): Promise<void>;
1768
+ /**
1769
+ * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
1770
+ * until the stale flag stays clear, then release the slot.
1771
+ *
1772
+ * NOTE: correct only because {@link membershipGateDirty} is guaranteed TRUE at
1773
+ * entry — its sole caller sets it synchronously, with no await in between. Were
1774
+ * this ever invoked with the flag already clear, the body would never suspend,
1775
+ * so the `finally` would null the slot BEFORE the caller's `??=` filled it, and
1776
+ * the slot would be left holding an already-settled drain that every later
1777
+ * refresh reuses — an unbreakable await/re-check spin. If a second caller is
1778
+ * ever added, have it mark the flag first (or assert it here).
1779
+ */
1780
+ private drainMembershipGate;
1781
+ /**
1782
+ * Collapse a burst of `CadrePeer` writes into ONE gate refresh at scope exit.
1783
+ *
1784
+ * For loops that re-touch many rows (the write-while-alone drains), where a
1785
+ * per-row refresh would mean one full membership read per row for a snapshot
1786
+ * that only has to be correct once the loop settles.
1787
+ *
1788
+ * The depth counter is instance-level, so it also suppresses a genuinely
1789
+ * CONCURRENT external write's refresh for the life of the scope: that writer's
1790
+ * promise resolves before its peer is admitted, and admission lands at scope
1791
+ * exit instead. Acceptable — scope exit is milliseconds away (bounded by the
1792
+ * drain), versus the ~15 s reconcile interval that was the alternative before
1793
+ * any of this existed — but it is the reason these scopes stay short and rare.
1794
+ */
1795
+ private deferMembershipGateRefresh;
938
1796
  /**
939
1797
  * Authorize a new peer to join the cadre.
940
1798
  * Signs the peer ID with the owner key and inserts into CadrePeer table.
@@ -967,6 +1825,13 @@ export declare class CadreNode implements SAppIdLookup {
967
1825
  applySeed(seed: ControlNetworkSeed, options?: {
968
1826
  trustPolicy?: SeedTrustPolicy;
969
1827
  }): Promise<ApplySeedResult>;
1828
+ /**
1829
+ * Post-process an {@link applySeed} outcome: retain the seed's owner peers as
1830
+ * cold-start bootstrap targets, and surface a seed that was accepted but whose
1831
+ * every owner dial failed — the node is now seeded yet unconnected, and the
1832
+ * cold-start reconcile branch is what gets it out of that state.
1833
+ */
1834
+ private noteAppliedSeed;
970
1835
  /**
971
1836
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
972
1837
  */