@serfab/cadre-core 0.8.1 → 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 (197) hide show
  1. package/README.md +6 -4
  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 +1006 -101
  9. package/dist/cadre-node.js +1977 -283
  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 +15 -15
  15. package/dist/control-cohort.js +14 -14
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +1033 -76
  18. package/dist/control-database.js +1520 -153
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +91 -12
  21. package/dist/control-formation-recorder.js +131 -16
  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 +774 -186
  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 +151 -0
  30. package/dist/control-write-retry.js +411 -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 +12 -12
  36. package/dist/device-token.js +15 -14
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +68 -0
  39. package/dist/ed25519-key.js +106 -0
  40. package/dist/ed25519-key.js.map +1 -0
  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 +16 -6
  54. package/dist/index.js +44 -10
  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/key-store.d.ts +1 -1
  60. package/dist/key-store.js +1 -1
  61. package/dist/membership-connection-gater.d.ts +146 -0
  62. package/dist/membership-connection-gater.js +180 -0
  63. package/dist/membership-connection-gater.js.map +1 -0
  64. package/dist/node-local-snapshot.d.ts +109 -0
  65. package/dist/node-local-snapshot.js +182 -0
  66. package/dist/node-local-snapshot.js.map +1 -0
  67. package/dist/peer-authorization.d.ts +150 -11
  68. package/dist/peer-authorization.js +170 -13
  69. package/dist/peer-authorization.js.map +1 -1
  70. package/dist/peer-record.d.ts +24 -11
  71. package/dist/peer-record.js +29 -13
  72. package/dist/peer-record.js.map +1 -1
  73. package/dist/push-node.d.ts +38 -0
  74. package/dist/push-node.js +48 -0
  75. package/dist/push-node.js.map +1 -0
  76. package/dist/push-notifier-apns.d.ts +10 -2
  77. package/dist/push-notifier-apns.js +14 -10
  78. package/dist/push-notifier-apns.js.map +1 -1
  79. package/dist/push-notifier-fcm.d.ts +7 -1
  80. package/dist/push-notifier-fcm.js +9 -10
  81. package/dist/push-notifier-fcm.js.map +1 -1
  82. package/dist/push-notifier.d.ts +16 -35
  83. package/dist/push-notifier.js +16 -44
  84. package/dist/push-notifier.js.map +1 -1
  85. package/dist/relay-addrs.d.ts +45 -0
  86. package/dist/relay-addrs.js +103 -0
  87. package/dist/relay-addrs.js.map +1 -0
  88. package/dist/relay-reservation.d.ts +266 -0
  89. package/dist/relay-reservation.js +613 -0
  90. package/dist/relay-reservation.js.map +1 -0
  91. package/dist/seed-bootstrap.d.ts +263 -79
  92. package/dist/seed-bootstrap.js +413 -211
  93. package/dist/seed-bootstrap.js.map +1 -1
  94. package/dist/seed-trust-policy.d.ts +50 -24
  95. package/dist/seed-trust-policy.js +28 -19
  96. package/dist/seed-trust-policy.js.map +1 -1
  97. package/dist/strand-addr-protocol.d.ts +42 -13
  98. package/dist/strand-addr-protocol.js +58 -52
  99. package/dist/strand-addr-protocol.js.map +1 -1
  100. package/dist/strand-backfill.d.ts +109 -0
  101. package/dist/strand-backfill.js +279 -0
  102. package/dist/strand-backfill.js.map +1 -0
  103. package/dist/strand-database.d.ts +5 -5
  104. package/dist/strand-database.js +4 -4
  105. package/dist/strand-database.js.map +1 -1
  106. package/dist/strand-formation-manager.d.ts +38 -9
  107. package/dist/strand-formation-manager.js +120 -17
  108. package/dist/strand-formation-manager.js.map +1 -1
  109. package/dist/strand-formation-protocol.d.ts +104 -5
  110. package/dist/strand-formation-protocol.js +233 -5
  111. package/dist/strand-formation-protocol.js.map +1 -1
  112. package/dist/strand-instance-manager.d.ts +40 -2
  113. package/dist/strand-instance-manager.js +93 -15
  114. package/dist/strand-instance-manager.js.map +1 -1
  115. package/dist/strand-member-key.d.ts +6 -6
  116. package/dist/strand-member-key.js +6 -6
  117. package/dist/strand-member-key.js.map +1 -1
  118. package/dist/strand-member-registry.d.ts +12 -12
  119. package/dist/strand-member-registry.js +22 -14
  120. package/dist/strand-member-registry.js.map +1 -1
  121. package/dist/strand-membership-writer.d.ts +532 -138
  122. package/dist/strand-membership-writer.js +800 -179
  123. package/dist/strand-membership-writer.js.map +1 -1
  124. package/dist/strand-solicitation.d.ts +99 -33
  125. package/dist/strand-solicitation.js +100 -52
  126. package/dist/strand-solicitation.js.map +1 -1
  127. package/dist/strand-transport-key.d.ts +40 -0
  128. package/dist/strand-transport-key.js +56 -0
  129. package/dist/strand-transport-key.js.map +1 -0
  130. package/dist/strand-wake-protocol.d.ts +9 -7
  131. package/dist/strand-wake-protocol.js +22 -46
  132. package/dist/strand-wake-protocol.js.map +1 -1
  133. package/dist/strand-watcher.d.ts +17 -2
  134. package/dist/strand-watcher.js +47 -3
  135. package/dist/strand-watcher.js.map +1 -1
  136. package/dist/trusted-owner-store-file.d.ts +12 -0
  137. package/dist/trusted-owner-store-file.js +39 -0
  138. package/dist/trusted-owner-store-file.js.map +1 -0
  139. package/dist/trusted-owner-store.d.ts +64 -0
  140. package/dist/trusted-owner-store.js +126 -0
  141. package/dist/trusted-owner-store.js.map +1 -0
  142. package/dist/types.d.ts +325 -50
  143. package/dist/types.js +13 -0
  144. package/dist/types.js.map +1 -1
  145. package/package.json +23 -8
  146. package/src/bootstrap-peer-store-file.ts +44 -0
  147. package/src/bootstrap-peer-store.ts +172 -0
  148. package/src/cadre-node.ts +2129 -295
  149. package/src/control-authorization.ts +114 -0
  150. package/src/control-cohort.ts +17 -17
  151. package/src/control-database.ts +1766 -191
  152. package/src/control-formation-recorder.ts +174 -21
  153. package/src/control-schema.ts +786 -198
  154. package/src/control-stream.ts +81 -4
  155. package/src/control-write-retry.ts +473 -0
  156. package/src/delegate-admission.ts +302 -0
  157. package/src/device-token.ts +19 -14
  158. package/src/ed25519-key.ts +134 -0
  159. package/src/file-durable-slot.ts +61 -0
  160. package/src/formation-approval.ts +604 -0
  161. package/src/fs-atomic.ts +107 -0
  162. package/src/identity-key.ts +109 -0
  163. package/src/index.ts +157 -24
  164. package/src/key-store-file.ts +10 -81
  165. package/src/key-store.ts +1 -1
  166. package/src/membership-connection-gater.ts +204 -0
  167. package/src/node-local-snapshot.ts +260 -0
  168. package/src/peer-authorization.ts +219 -13
  169. package/src/peer-record.ts +31 -13
  170. package/src/push-node.ts +64 -0
  171. package/src/push-notifier-apns.ts +16 -12
  172. package/src/push-notifier-fcm.ts +9 -10
  173. package/src/push-notifier.ts +16 -57
  174. package/src/relay-addrs.ts +111 -0
  175. package/src/relay-reservation.ts +823 -0
  176. package/src/seed-bootstrap.ts +490 -226
  177. package/src/seed-trust-policy.ts +65 -32
  178. package/src/strand-addr-protocol.ts +84 -53
  179. package/src/strand-backfill.ts +366 -0
  180. package/src/strand-database.ts +6 -6
  181. package/src/strand-formation-manager.ts +145 -19
  182. package/src/strand-formation-protocol.ts +310 -9
  183. package/src/strand-instance-manager.ts +113 -16
  184. package/src/strand-member-key.ts +7 -7
  185. package/src/strand-member-registry.ts +28 -23
  186. package/src/strand-membership-writer.ts +1430 -640
  187. package/src/strand-solicitation.ts +155 -90
  188. package/src/strand-transport-key.ts +58 -0
  189. package/src/strand-wake-protocol.ts +29 -46
  190. package/src/strand-watcher.ts +66 -3
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +342 -51
  194. package/dist/authority-key.d.ts +0 -41
  195. package/dist/authority-key.js +0 -52
  196. package/dist/authority-key.js.map +0 -1
  197. package/src/authority-key.ts +0 -68
@@ -1,15 +1,19 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
- import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
4
- import { peerIdFromString } from '@libp2p/peer-id';
3
+ import { peerIdFromString, peerIdFromPrivateKey } from '@libp2p/peer-id';
5
4
  import { createLibp2pNode } from '@optimystic/db-p2p';
6
5
  import { multiaddr } from '@multiformats/multiaddr';
7
- import { DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
6
+ import { CONTROL_CLUSTER_POLICY, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
8
7
  import { sign } from '@optimystic/quereus-plugin-crypto';
9
- import { authorityKeyFromLibp2p } from './authority-key.js';
8
+ import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
9
+ import { strandTransportKey } from './strand-transport-key.js';
10
10
  import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
11
+ import { loadOrCreateIdentityKey } from './identity-key.js';
12
+ import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
13
+ import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
14
+ import { verifyCadrePeerVoucher } from './peer-authorization.js';
11
15
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
12
- import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
16
+ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
13
17
  import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
14
18
  import { StrandWatcher } from './strand-watcher.js';
15
19
  import { StrandInstanceManager } from './strand-instance-manager.js';
@@ -20,8 +24,12 @@ import { HibernationManager } from './hibernation-manager.js';
20
24
  import { ControlDatabase } from './control-database.js';
21
25
  import { SeedBootstrapService } from './seed-bootstrap.js';
22
26
  import { StrandSolicitationService } from './strand-solicitation.js';
27
+ import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS } from './membership-connection-gater.js';
23
28
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
24
29
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
30
+ import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, pruneStoppedStrandAnnounces, peerStrandKey } from './delegate-admission.js';
31
+ import { resolveListenAddrs } from './relay-addrs.js';
32
+ import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
25
33
  import { PushFanoutService } from './push-fanout.js';
26
34
  import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
27
35
  import { TurnRelayTracker } from './diagnostics/webrtc-turn-tracker.js';
@@ -33,6 +41,33 @@ const timing = debug('sereus:cadre:timing');
33
41
  * `RTCPeerConnection` reaching `connected` and libp2p surfacing the connection.
34
42
  */
35
43
  const TURN_CONSUME_WINDOW_MS = 1000;
44
+ /**
45
+ * A circuit relay as a strand-addr RPC target: dialed by peerId, with its
46
+ * direct addr riding along as the fallback for a relay we hold no connection to.
47
+ */
48
+ function relayStrandAddrPeer(relay) {
49
+ return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
50
+ }
51
+ /**
52
+ * Build the signing callback the `ControlDatabase` writers expect: they hand it the
53
+ * canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
54
+ * them directly (no pre-hash) with the owner private key, returning a base64url signature.
55
+ */
56
+ function signMessageWith(privateKeyB64) {
57
+ return (message) => sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
58
+ }
59
+ /**
60
+ * Reject a blank identifier before it reaches the control database, where it would fail an
61
+ * authorization CHECK as an opaque constraint error rather than an actionable message.
62
+ * Returns the trimmed value so callers write the same bytes they validated.
63
+ */
64
+ function requireNonBlank(value, label) {
65
+ const trimmed = value.trim();
66
+ if (trimmed.length === 0) {
67
+ throw new Error(`A ${label} is required (received an empty or whitespace-only value)`);
68
+ }
69
+ return trimmed;
70
+ }
36
71
  /**
37
72
  * CadreNode is the main entry point for a cadre member.
38
73
  * It manages:
@@ -44,6 +79,15 @@ const TURN_CONSUME_WINDOW_MS = 1000;
44
79
  */
45
80
  export class CadreNode {
46
81
  constructor(config) {
82
+ /**
83
+ * Node-local, NON-replicated trusted-owner anchor (see
84
+ * `trusted-owner-store.ts`). Constructed (or adopted from
85
+ * `config.trustedOwners.store`) by {@link initializeTrustedOwnerStore} during
86
+ * {@link start}; deliberately NOT cleared by {@link cleanup}, so an in-memory
87
+ * anchor survives a stop()→start() cycle of the same node instance. Never
88
+ * sourced from replicated control state.
89
+ */
90
+ this.trustedOwnerStore = null;
47
91
  this.controlNode = null;
48
92
  this.controlDatabase = null;
49
93
  this.strandWatcher = null;
@@ -59,9 +103,9 @@ export class CadreNode {
59
103
  this.strandAddrService = null;
60
104
  /**
61
105
  * Server-side push-wake fan-out. Constructed by {@link start} only when
62
- * `config.push` (FCM/APNs credentials) is present — without it the node behaves
63
- * exactly as before (no notifier, no fan-out). Owns who/when to wake
64
- * hibernating mobile peers on strand activity.
106
+ * `config.push` (an injected `PushNotifier` + policy) is present — without it
107
+ * the node behaves exactly as before (no notifier, no fan-out). Owns who/when
108
+ * to wake hibernating mobile peers on strand activity.
65
109
  */
66
110
  this.pushFanoutService = null;
67
111
  /** Backing field for the {@link running} / {@link isRunning} getters. */
@@ -80,6 +124,25 @@ export class CadreNode {
80
124
  */
81
125
  this.windowWaiters = new Set();
82
126
  this.eventHandlers = new Map();
127
+ /**
128
+ * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through.
129
+ * Empty (the default) means nobody asked, so {@link getRelayReservationState}
130
+ * reports `none` — this whole surface is opt-in and dormant for the CLI/host
131
+ * nodes, which use the configured `network.relayAddrs` route instead.
132
+ */
133
+ this.relayReserveAddrs = [];
134
+ /**
135
+ * The running retry loop for {@link relayReserveAddrs}, or `null` when nobody
136
+ * asked for a reservation / there was no control node to supervise. It owns the
137
+ * in-flight flag, the last failure and the next-attempt time; this class only
138
+ * starts it, stops it and reads it.
139
+ */
140
+ this.relayReserveSupervisor = null;
141
+ /**
142
+ * Reason {@link reserveRelays} produced no reservation when there is no
143
+ * supervisor to hold one — i.e. the pre-start `control node unavailable` case.
144
+ */
145
+ this.relayReserveError = null;
83
146
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
84
147
  this.sAppConfigs = new Map();
85
148
  /**
@@ -89,6 +152,106 @@ export class CadreNode {
89
152
  * control-network node never needs to dial back to the manager.
90
153
  */
91
154
  this.latestInviteAddresses = null;
155
+ /**
156
+ * Epoch ms until which the control-network inbound gate admits
157
+ * not-yet-authorized peers (see {@link admitInboundControlConnection} /
158
+ * {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
159
+ * {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
160
+ * inside an outstanding invite's validity must not strand the invitee.
161
+ *
162
+ * NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
163
+ * the owner re-mints or the invitee is authorized. Fine while invites are
164
+ * short-lived and redeemed promptly; if long-lived invites or restart-prone
165
+ * hosts become normal, persist the window (or derive it from the issued-invite
166
+ * records `SeedBootstrapService` already keeps).
167
+ */
168
+ this.enrollmentWindowUntil = 0;
169
+ /**
170
+ * Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
171
+ * {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
172
+ */
173
+ this.bootstrapPeerIds = null;
174
+ /**
175
+ * Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
176
+ * {@link listAuthorizedMembers} result), consulted by the per-stream
177
+ * control-DB gate ({@link authorizeInboundControlStream}). That predicate
178
+ * runs on EVERY inbound Optimystic control-DB stream and must never await a
179
+ * control-DB read: those reads pull blocks over the very protocols the
180
+ * predicate gates, which closes a circular wait that ends in mutual denial
181
+ * (the upstream gate is fail-closed on timeout, unlike the fail-open
182
+ * connection gater). So the set is refreshed OUT OF BAND instead — after
183
+ * start, on every {@link reconcileControlCohort} pass (15s cadence), and
184
+ * immediately after every LOCAL membership mutation so a just-vouched peer
185
+ * is admitted without waiting for the timer. That last refresh is AUTOMATIC:
186
+ * the control database notifies {@link refreshMembershipGate} after every
187
+ * committed `CadrePeer` write (see `ControlDatabase.mutateCadrePeer`), so no
188
+ * writer has to remember. A change that arrives by REPLICATION is picked up
189
+ * on the next timed refresh — bounded staleness, acceptable because this gate
190
+ * is defense in depth: rows an unadmitted peer manages to write are still
191
+ * disbelieved at read time by the voucher-anchored predicate.
192
+ */
193
+ this.authorizedControlPeers = new Set();
194
+ /**
195
+ * Coalescing state for {@link refreshMembershipGate}: a pending "the snapshot
196
+ * is stale" flag, the single in-flight drain that consumes it, and the depth
197
+ * of open {@link deferMembershipGateRefresh} scopes (a burst of writes inside
198
+ * one scope collapses to a single refresh at scope exit).
199
+ */
200
+ this.membershipGateDirty = false;
201
+ this.membershipGateDrain = null;
202
+ this.membershipGateDeferDepth = 0;
203
+ /**
204
+ * In-memory delegate admission grants (see `delegate-admission.ts`): the
205
+ * strand-node transport peerIds this party's members have announced over the
206
+ * strand-addr RPC, admitted at the CONNECTION level so a member's NAT'd
207
+ * strand node can hold a circuit-relay reservation on this node. Consulted
208
+ * ONLY by {@link admitInboundControlConnection}; the fail-closed per-stream
209
+ * gate ({@link authorizeInboundControlStream}) never honors it.
210
+ */
211
+ this.delegateAdmission = new DelegateAdmissionStore();
212
+ /**
213
+ * When this node last ANNOUNCED a delegate, keyed `<targetPeerId>\n<strandId>`
214
+ * (the peer announced TO, not the delegate). Throttles
215
+ * {@link refreshDelegateGrants} to once per `DELEGATE_GRANT_TTL_MS / 2` per
216
+ * (relay, strand) so the 15 s reconcile tick never becomes per-tick RPC
217
+ * chatter; the launch/resume announce passes record here too. Keys whose
218
+ * strand is no longer running are pruned on each reconcile pass.
219
+ */
220
+ this.delegateAnnounceAt = new Map();
221
+ /**
222
+ * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
223
+ * this node has applied, keyed by peer id, valued by the seed's multiaddr
224
+ * strings (parsed lazily, at dial time). Written by
225
+ * {@link recordSeedBootstrapPeers}; read only by
226
+ * {@link dialColdStartBootstrap} while the control database still has no
227
+ * siblings to dial.
228
+ *
229
+ * A STORE rather than a plain map (see `bootstrap-peer-store.ts`), because the
230
+ * targets must outlive the process: a node seeded into a party it could not
231
+ * reach has nothing else on disk naming that party's addresses (`applySeed`
232
+ * writes no control row, and `CadrePeer` fills in only after a connection
233
+ * succeeds), so an in-memory-only set left it stranded permanently across a
234
+ * restart. Constructed (or adopted from `config.bootstrapPeers.store`) by
235
+ * {@link initializeBootstrapPeerStore} during {@link start}; deliberately NOT
236
+ * cleared by {@link cleanup}, so it survives a stop()→start() cycle of the same
237
+ * node instance — same lifecycle as {@link trustedOwnerStore}. Durability
238
+ * depends on the injected backend: Node gets a file-backed store from the CLI,
239
+ * RN/browser stay ephemeral for now.
240
+ *
241
+ * Deliberately NOT the libp2p peer store, which {@link peerStoreAddrs} already
242
+ * reads for the steady-state path. The peer store is shared with everything
243
+ * libp2p discovers, so "dial every entry" would grow into dialing arbitrary
244
+ * discovered peers as the node lives longer; this store holds exactly the peers
245
+ * an owner-signed, trust-anchored seed nominated as owners. `applySeed` merges
246
+ * the same addresses into the peer store as well, so the two never disagree —
247
+ * this one is just the precisely-scoped subset.
248
+ *
249
+ * A later seed OVERWRITES an entry rather than merging, so a re-seed after an
250
+ * owner's address changes replaces the stale address instead of accumulating
251
+ * dead ones. Entries are never evicted: they are the node's only way back into
252
+ * the party if it is ever stranded again.
253
+ */
254
+ this.bootstrapPeerStore = null;
92
255
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
93
256
  this.selfRegistrationTimer = null;
94
257
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -111,28 +274,51 @@ export class CadreNode {
111
274
  this.reconcileControlCohortInFlight = null;
112
275
  /**
113
276
  * Single-flight guard for {@link registerSelf}. Concurrent callers (the explicit
114
- * CLI `--authority` publish, the 1s startup timer, the TTL heartbeat, and the
277
+ * CLI `--owner` publish, the 1s startup timer, the TTL heartbeat, and the
115
278
  * address-change listener) share one in-flight publish so two of them can never
116
279
  * both read "no row yet" and race a duplicate INSERT (a `CadrePeer` PK conflict).
117
280
  */
118
281
  this.registerSelfInFlight = null;
119
282
  // ── Write-while-alone re-replication (control-write-ensure-replicated) ──────
120
283
  /**
121
- * Re-replication queue for authority control writes that committed while this
122
- * node was alone — `controlNode.getConnections().length === 0` at write time
123
- * means the Optimystic commit was local-only (the block's cluster was ≤1) and
124
- * never broadcast. Maps the affected subject peerId → the write to re-issue once
125
- * the cohort grows. A later op for the same subject overwrites an earlier one
126
- * (authorize then remove → remove wins). Drained on the 0→≥1 control-connection
127
- * transition by {@link drainPendingControlReplication}.
284
+ * Re-replication queue for owner `authorizePeer` writes that committed while
285
+ * this node was alone — `controlNode.getConnections().length === 0` at write
286
+ * time means the Optimystic commit was local-only (the block's cluster was ≤1)
287
+ * and never broadcast. Maps the affected subject peerId → 'authorize', to
288
+ * re-issue once the cohort grows. Removals are NOT tracked here — a delete
289
+ * leaves a `Revocation` tombstone, tracked by {@link pendingRevocations}; a
290
+ * removePeer also clears any queued authorize for the same subject (the row is
291
+ * gone, so re-issuing the insert would be wrong). Drained on the 0→≥1
292
+ * control-connection transition by {@link drainPendingControlReplication}.
128
293
  */
129
294
  this.pendingPeerWrites = new Map();
295
+ /**
296
+ * Re-replication queue for `Revocation` tombstones that committed while this
297
+ * node was alone, keyed on the retired `StampId`. Fed by the control DB's
298
+ * committed-delete seam ({@link noteGuardedDelete}), so one queue covers all
299
+ * four guarded tables (`CadrePeer` / `DeviceToken` / `Strand` /
300
+ * `ValidationKey`). Drained (owner-signed `ReissuedAt` bump, which re-broadcasts
301
+ * the tombstone) by {@link drainPendingRevocations}. Cleared on stop alongside
302
+ * {@link pendingPeerWrites} — the tombstone rows are durable, so the next
303
+ * lifetime's first-growth sweep re-covers anything dropped here.
304
+ */
305
+ this.pendingRevocations = new Map();
306
+ /**
307
+ * Guards the once-per-lifetime first-growth revocation sweep (re-issue EVERY
308
+ * locally-held tombstone, covering removals from before this node started).
309
+ * Deliberately separate from {@link reconstructedLocalOnlyWrites} and set only
310
+ * after a sweep pass SUCCEEDS (including the nothing-held case) — a throwing
311
+ * sweep retries on the next growth edge instead of being lost for the lifetime.
312
+ * Reset on stop, so a stop()→start() cycle sweeps again (that second start is
313
+ * exactly a "removals from before this lifetime" case).
314
+ */
315
+ this.reissuedHeldRevocations = false;
130
316
  /** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
131
317
  this.pendingSelfPeerWrite = false;
132
318
  /** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
133
319
  this.pendingSelfDeviceWrite = false;
134
320
  /**
135
- * Guards the one-shot, first-cohort-growth reconstruction: an authority re-touches
321
+ * Guards the one-shot, first-cohort-growth reconstruction: an owner re-touches
136
322
  * every membership row it may have authored that could be unreplicated (covering
137
323
  * writes made before this process started, which the in-memory queue cannot know).
138
324
  * Set true after the first drain so later passes only drain the in-memory queue.
@@ -282,6 +468,14 @@ export class CadreNode {
282
468
  return;
283
469
  }
284
470
  log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
471
+ // NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
472
+ // diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
473
+ // second such warning ever appears here, route both through a `CadreNodeEvents` entry the
474
+ // embedder surfaces instead of growing a console surface inside a library.
475
+ if (this.config.network?.announceAddrs && this.config.network.announceAddrs.length > 0) {
476
+ console.warn('network.announceAddrs is set but not yet supported (no upstream db-p2p option to apply it) ' +
477
+ '— this node will keep advertising its listen/relay addresses instead.');
478
+ }
285
479
  try {
286
480
  const tTotal = performance.now();
287
481
  // Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
@@ -292,6 +486,16 @@ export class CadreNode {
292
486
  // libp2p/network bring-up, so a misconfiguration or an access-denied secure
293
487
  // store fails closed before a node is created.
294
488
  await this.resolveIdentityKey();
489
+ // Bring up the node-local trusted-owner anchor (and seed config pins)
490
+ // before any network bring-up: a mis-scoped injected store fails closed
491
+ // here, and out-of-band pins are anchored before the first seed/peer
492
+ // interaction could consult them.
493
+ await this.initializeTrustedOwnerStore();
494
+ // Bring up the node-local cold-start bootstrap-peer store alongside the
495
+ // anchor, and for the same two reasons: a mis-scoped injected store fails
496
+ // closed before any network bring-up, and the retained dial targets are
497
+ // loaded before the first reconcile pass could consult them.
498
+ this.initializeBootstrapPeerStore();
295
499
  // Create the control network libp2p node
296
500
  let t0 = performance.now();
297
501
  this.controlNode = await this.createControlNode();
@@ -313,6 +517,15 @@ export class CadreNode {
313
517
  await this.controlDatabase.initialize();
314
518
  timing('[start] controlDatabase.initialize: %dms', Math.round(performance.now() - t0));
315
519
  log('Control database initialized');
520
+ // Attach the per-stream gate to the control DB's membership hub, so every
521
+ // committed `CadrePeer` write re-materializes the authorized snapshot on its
522
+ // own. Wired before anything can write. `_running` is still false for the
523
+ // rest of start() and the refresh early-returns while it is — a write in that
524
+ // window is covered by start's own pass below.
525
+ this.controlDatabase.setMembershipChangeListener((reason) => this.refreshMembershipGate(reason));
526
+ // Committed-delete seam → write-while-alone revocation queue. Same wiring
527
+ // window argument as the membership listener above.
528
+ this.controlDatabase.setGuardedDeleteListener((revocation) => this.noteGuardedDelete(revocation));
316
529
  // Create strand queryable using the control database
317
530
  const queryable = this.createStrandQueryable();
318
531
  // Create and start the strand watcher with sAppId lookup
@@ -327,10 +540,11 @@ export class CadreNode {
327
540
  // Start hibernation manager
328
541
  this.hibernationManager.start();
329
542
  // Register the control-network push-wake receiver: a same-cadre peer can
330
- // signal us to bring a hibernating strand online. Gated on CadrePeer
331
- // membership; the wake routes through the same path as a local wake.
543
+ // signal us to bring a hibernating strand online. Gated on AUTHORIZED
544
+ // membership (not the addressable surface); the wake routes through the same
545
+ // path as a local wake.
332
546
  this.strandWakeService = new StrandWakeService({
333
- isMember: (peerId) => this.isMember(peerId),
547
+ isMember: (peerId) => this.isAuthorizedMember(peerId),
334
548
  getStrand: (strandId) => this.strandManager.getInstance(strandId),
335
549
  wake: (strandId) => this.wakeStrand(strandId),
336
550
  });
@@ -338,32 +552,21 @@ export class CadreNode {
338
552
  // Register the control-network strand-address responder: a same-cadre peer
339
553
  // resolving a strand's bootstrap seed asks us for our live strand-network
340
554
  // multiaddrs (its CadrePeer row only knows our *control* address). Gated on
341
- // CadrePeer membership; answers only for strands we are actively meshing.
555
+ // AUTHORIZED membership; answers only for strands we are actively meshing.
556
+ // The same RPC doubles as the delegate-announce channel: a member's request
557
+ // may carry the derived peerId its strand node runs as, and we record an
558
+ // admission grant so our connection gate (and thus our relay server, when
559
+ // enabled) admits it — see delegate-admission.ts.
342
560
  this.strandAddrService = new StrandAddrService({
343
- isMember: (peerId) => this.isMember(peerId),
561
+ isMember: (peerId) => this.isAuthorizedMember(peerId),
344
562
  getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
563
+ onDelegateAnnounce: (announcer, strandId, delegate) => this.grantDelegateAdmission(announcer, strandId, delegate),
345
564
  });
346
565
  this.strandAddrService.initialize(this.controlNode);
347
- // Server-side push-wake fan-out: only when push credentials are configured.
348
- // The PushNotifier reaches for node:http2/node:crypto, so it is loaded via a
349
- // guarded DYNAMIC import — a cross-platform (RN/browser) node that never sets
350
- // config.push never pulls those modules into its static graph. The fan-out
351
- // service itself is import-clean (PushNotifier type only) and statically imported.
566
+ // Server-side push-wake fan-out: only when push is configured.
352
567
  if (this.config.push) {
353
- const { createPushNotifier } = await import('./push-notifier.js');
354
- const notifier = createPushNotifier(this.config.push);
355
- this.pushFanoutService = new PushFanoutService({
356
- listMembers: () => this.listMembers(),
357
- getStrand: (strandId) => this.strandManager.getInstance(strandId),
358
- selfPeerId: () => this.controlNode?.peerId.toString(),
359
- pushWake: (peerId, strandId, reason) => this.pushWake(peerId, strandId, reason),
360
- resolveDeviceToken: (peerId) => this.resolveDeviceToken(peerId),
361
- expireDeviceToken: (peerId) => this.expireDeviceToken(peerId),
362
- notifier,
363
- cooldownMs: this.config.push.cooldownMs,
364
- debounceMs: this.config.push.debounceMs,
365
- });
366
- log('Push-wake fan-out enabled (FCM=%s, APNs=%s)', !!this.config.push.fcm, !!this.config.push.apns);
568
+ this.pushFanoutService = this.buildPushFanout(this.config.push);
569
+ log('Push-wake fan-out enabled (injected notifier)');
367
570
  }
368
571
  this._running = true;
369
572
  this.emit('control:connected', undefined);
@@ -373,6 +576,11 @@ export class CadreNode {
373
576
  // delayed refresh path) so no early `connection:open` is missed — the
374
577
  // write-while-alone re-replication drain must fire on the first 0→≥1 edge.
375
578
  this.wireControlConnectionListeners();
579
+ // Seed the per-stream gate's materialized authorized set now that the
580
+ // control DB is up (non-blocking; the helper never rejects). Until it
581
+ // lands the snapshot is empty, which the stream gate treats as cold
582
+ // start and admits.
583
+ void this.refreshMembershipGate('start');
376
584
  // Schedule self-registration in background
377
585
  this.scheduleSelfRegistration();
378
586
  }
@@ -382,6 +590,31 @@ export class CadreNode {
382
590
  throw error;
383
591
  }
384
592
  }
593
+ /**
594
+ * Build the server-side push-wake fan-out over an injected {@link PushNotifier}.
595
+ *
596
+ * The notifier reaches for `node:http2`/`node:crypto`, so the cross-platform
597
+ * core never constructs it — the Node host builds it from
598
+ * `@serfab/cadre-core/push-node` and passes the instance in
599
+ * `CadreNodeConfig.push`. This node owns that instance's lifecycle from here:
600
+ * {@link cleanup} closes the fan-out, which closes the notifier (freeing the
601
+ * APNs HTTP/2 session). Every other primitive the fan-out needs (member
602
+ * enumeration, participation, direct dial, token resolve/expire) is an
603
+ * import-clean closure over this node.
604
+ */
605
+ buildPushFanout(push) {
606
+ return new PushFanoutService({
607
+ listMembers: () => this.listMembers(),
608
+ getStrand: (strandId) => this.strandManager.getInstance(strandId),
609
+ selfPeerId: () => this.controlNode?.peerId.toString(),
610
+ pushWake: (peerId, strandId, reason) => this.pushWake(peerId, strandId, reason),
611
+ resolveDeviceToken: (peerId) => this.resolveDeviceToken(peerId),
612
+ expireDeviceToken: (peerId) => this.expireDeviceToken(peerId),
613
+ notifier: push.notifier,
614
+ cooldownMs: push.cooldownMs,
615
+ debounceMs: push.debounceMs,
616
+ });
617
+ }
385
618
  /**
386
619
  * Stop the cadre node
387
620
  */
@@ -391,7 +624,18 @@ export class CadreNode {
391
624
  return;
392
625
  }
393
626
  log('Stopping CadreNode');
627
+ // Stop the retry loop BEFORE tearing the node down, not after: `cleanup()`
628
+ // stops the control node partway through, and a tick that fires during it
629
+ // would dial and then poll a half-torn-down node for the whole reserve
630
+ // timeout — logging spurious dial failures and holding the process open.
631
+ this.relayReserveSupervisor?.stop();
632
+ this.relayReserveSupervisor = null;
394
633
  await this.cleanup();
634
+ // Drop the rest of the relay-reservation posture: a stopped node holds no
635
+ // reservation, so a restarted instance must not report the previous run's
636
+ // `reserved`.
637
+ this.relayReserveAddrs = [];
638
+ this.relayReserveError = null;
395
639
  this._running = false;
396
640
  this.emit('control:disconnected', undefined);
397
641
  log('CadreNode stopped');
@@ -426,11 +670,11 @@ export class CadreNode {
426
670
  * before any network bring-up. Resolution order:
427
671
  *
428
672
  * 1. Both `keyStore` and `privateKey` set ⇒ configuration error (throws).
429
- * 2. `keyStore` set ⇒ load protobuf bytes from `identityKeyId` (default
430
- * {@link DEFAULT_IDENTITY_KEY_ID}). Found ⇒ deserialize; empty ⇒ generate a
431
- * fresh Ed25519 key, persist it, and use it. A rejected `get` (access denied
432
- * / backend failure) PROPAGATES — we never generate a new key on a read
433
- * error, which would silently orphan the real identity.
673
+ * 2. `keyStore` set ⇒ {@link loadOrCreateIdentityKey} against `identityKeyId`
674
+ * (default {@link DEFAULT_IDENTITY_KEY_ID}): load when the slot is present,
675
+ * generate + persist when it is empty. A rejected `get` (access denied /
676
+ * backend failure) PROPAGATES — we never generate a new key on a read error,
677
+ * which would silently orphan the real identity.
434
678
  * 3. `privateKey` set ⇒ use it directly.
435
679
  * 4. Neither ⇒ leave undefined; libp2p generates an ephemeral key.
436
680
  *
@@ -447,21 +691,11 @@ export class CadreNode {
447
691
  'configure at most one source for the node identity');
448
692
  }
449
693
  if (keyStore) {
450
- const keyId = identityKeyId ?? DEFAULT_IDENTITY_KEY_ID;
451
- // A rejection here (e.g. KeyStoreAccessError) must propagate — do NOT fall
452
- // through to generation, which would orphan an existing but unreadable key.
453
- const bytes = await keyStore.get(keyId);
454
- if (bytes) {
455
- // Corrupt/garbage bytes throw here; surface loudly rather than
456
- // regenerating (which would orphan the real identity).
457
- this.identityKey = privateKeyFromProtobuf(bytes);
458
- log('Identity key loaded from key store (slot present)');
459
- return;
460
- }
461
- const generated = await generateKeyPair('Ed25519');
462
- await keyStore.set(keyId, privateKeyToProtobuf(generated));
463
- this.identityKey = generated;
464
- log('Identity key generated and persisted to key store (first run)');
694
+ // Shared with the embedding app: `reference-app-rn` resolves the very same
695
+ // key before constructing this node so it can sign its ICE-manifest request
696
+ // with it (identity-key.ts). One copy of the rule, so neither side can
697
+ // drift into generating a second identity.
698
+ this.identityKey = await loadOrCreateIdentityKey(keyStore, identityKeyId ?? DEFAULT_IDENTITY_KEY_ID);
465
699
  return;
466
700
  }
467
701
  if (privateKey) {
@@ -470,9 +704,119 @@ export class CadreNode {
470
704
  }
471
705
  // Neither configured: libp2p generates an ephemeral key internally.
472
706
  }
707
+ /**
708
+ * Construct (or adopt) the node-local trusted-owner anchor and seed the
709
+ * out-of-band pinned keys from `config.trustedOwners`. The store is NEVER
710
+ * sourced from the replicated control DB — its entries come only from
711
+ * genesis self-trust ({@link initializeSeedBootstrap}), config pins (here),
712
+ * or runtime enrollment pins ({@link trustOwnerKeys}).
713
+ *
714
+ * Idempotent across stop()→start(): the store instance is kept, and
715
+ * re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
716
+ * idempotent). An injected store scoped to a different party is a
717
+ * configuration error (fail closed before any network bring-up).
718
+ */
719
+ async initializeTrustedOwnerStore() {
720
+ const { trustedOwners } = this.config;
721
+ const partyId = this.config.controlNetwork.partyId;
722
+ if (!this.trustedOwnerStore) {
723
+ const store = trustedOwners?.store ?? new MemoryTrustedOwnerStore(partyId);
724
+ if (store.partyId !== partyId) {
725
+ throw new Error(`CadreNodeConfig: trustedOwners.store is scoped to party ${store.partyId}, ` +
726
+ `but this node serves party ${partyId} — refusing to mix trust anchors`);
727
+ }
728
+ this.trustedOwnerStore = store;
729
+ }
730
+ // Validate every pin before trusting any: a malformed entry must not leave
731
+ // earlier, valid pins anchored while start() then fails on a later one —
732
+ // config pins land all-or-nothing, same as the runtime seam below.
733
+ const pinnedKeys = (trustedOwners?.pinnedKeys ?? []).map(key => requireEd25519PublicKeyB64(key, 'pinned owner key'));
734
+ for (const key of pinnedKeys) {
735
+ await this.trustedOwnerStore.trust(key, trustedOwners?.pinnedSource ?? 'operator');
736
+ }
737
+ }
738
+ /**
739
+ * Construct (or adopt) the node-local cold-start bootstrap-peer store (see
740
+ * {@link bootstrapPeerStore}). Synchronous: an injected store has already
741
+ * loaded its persisted targets by the time it is handed in (its `open` is the
742
+ * async part), and the in-memory fallback has nothing to load.
743
+ *
744
+ * Idempotent across stop()→start(): the store instance is kept, so retained
745
+ * targets survive a restart of the same node instance even with the ephemeral
746
+ * default. An injected store scoped to a different party is a configuration
747
+ * error (fail closed before any network bring-up) — a foreign party's addresses
748
+ * must never enter this node's dial loop.
749
+ */
750
+ initializeBootstrapPeerStore() {
751
+ const partyId = this.config.controlNetwork.partyId;
752
+ if (this.bootstrapPeerStore) {
753
+ return;
754
+ }
755
+ const store = this.config.bootstrapPeers?.store ?? new MemoryBootstrapPeerStore(partyId);
756
+ if (store.partyId !== partyId) {
757
+ throw new Error(`CadreNodeConfig: bootstrapPeers.store is scoped to party ${store.partyId}, ` +
758
+ `but this node serves party ${partyId} — refusing to mix cold-start dial targets`);
759
+ }
760
+ this.bootstrapPeerStore = store;
761
+ }
762
+ /**
763
+ * The node-local cold-start bootstrap-peer store (null before {@link start}) —
764
+ * the retained owner addresses {@link reconcileControlCohort}'s cold-start
765
+ * branch re-dials. Exposed for diagnostics and for a host that wants to show
766
+ * "what would this node dial if it is stranded?".
767
+ */
768
+ getBootstrapPeerStore() {
769
+ return this.bootstrapPeerStore;
770
+ }
771
+ /**
772
+ * The node-local trusted-owner anchor (null before {@link start}). This is
773
+ * the set the authorized-membership predicate and seed-trust anchor consult —
774
+ * never the replicated `OwnerKey` table, which any stranger can pollute.
775
+ */
776
+ getTrustedOwnerStore() {
777
+ return this.trustedOwnerStore;
778
+ }
779
+ /**
780
+ * Persist out-of-band-established owner keys into the node-local anchor —
781
+ * the runtime enrollment seam: call with `CadreInvite.ownerKeys` when
782
+ * redeeming an invite (BEFORE the first `applySeed`, so the anchor already
783
+ * holds the pins when seed trust consults it), or with an operator-supplied
784
+ * pin. Idempotent. ('genesis' provenance is reserved for the node's own
785
+ * founding key, seeded internally by {@link initializeSeedBootstrap}.)
786
+ *
787
+ * Validates every key's shape before trusting any of them (all-or-nothing):
788
+ * a malformed entry anywhere in `keys` rejects the whole call before a
789
+ * single key is anchored. For the invite route this means a `CadreInvite`
790
+ * carrying one malformed `ownerKeys` entry fails the redemption outright —
791
+ * consistent with the anchor's existing whole-record-or-nothing policy for
792
+ * a corrupt persisted entry (see `trusted-owner-store.ts`'s
793
+ * `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
794
+ * and leaving the caller to notice a key went missing.
795
+ */
796
+ async trustOwnerKeys(keys, source) {
797
+ if (!this.trustedOwnerStore) {
798
+ throw new Error('CadreNode must be started before trusting owner keys');
799
+ }
800
+ const validated = Array.from(keys, key => requireEd25519PublicKeyB64(key, 'pinned owner key'));
801
+ for (const key of validated) {
802
+ await this.trustedOwnerStore.trust(key, source);
803
+ }
804
+ }
473
805
  async createControlNode() {
806
+ return await createLibp2pNode(this.buildControlNodeOptions());
807
+ }
808
+ /**
809
+ * Map this node's config onto the control network's libp2p node options.
810
+ *
811
+ * Split out of {@link createControlNode} purely so the mapping is assertable without
812
+ * standing up a real libp2p node — `packages/cadre-core/test/cadre-node-control-node-options.spec.ts`
813
+ * calls it on a bare `new CadreNode(config)`. Read nothing else into the split; the
814
+ * only caller in production is `createControlNode`.
815
+ */
816
+ buildControlNodeOptions() {
474
817
  const { controlNetwork, network, storage, profile } = this.config;
475
818
  const identityKey = this.identityKey;
819
+ const listenAddrs = resolveListenAddrs(network);
476
820
  // Determine relay mode: if explicitly set in config, use that;
477
821
  // otherwise default to true for storage profile nodes (better connectivity/uptime)
478
822
  const enableRelay = network?.enableRelay ?? (profile === 'storage');
@@ -490,15 +834,279 @@ export class CadreNode {
490
834
  storage: controlStorageProvider,
491
835
  fretProfile: profile === 'storage' ? 'core' : 'edge',
492
836
  relay: enableRelay,
493
- clusterSize: 3,
494
- clusterPolicy: { allowDownsize: true, sizeTolerance: 0.5 },
495
- arachnode: { enableRingZulu: this.config.profile === 'storage' },
837
+ // Fixed, and deliberately above any party's node count: every member reads the
838
+ // whole control database, so a cohort that excludes a member leaves it dependent
839
+ // on read repair — which cannot converge at a two-member cohort. Not a knob;
840
+ // see CONTROL_REPLICATION_BREADTH. `assumedClusterSize: 2` is declared explicitly in
841
+ // CONTROL_CLUSTER_POLICY: only the admission gate defaults to 2, while the read-repair
842
+ // corroboration floor would otherwise fall back to this clusterSize of 16.
843
+ clusterSize: CONTROL_REPLICATION_BREADTH,
844
+ clusterPolicy: CONTROL_CLUSTER_POLICY,
845
+ arachnode: { enableRingZulu: profile === 'storage' },
496
846
  ...(identityKey && { privateKey: identityKey }),
497
847
  ...(network?.transports && { transports: network.transports }),
498
- ...(network?.listenAddrs && { listenAddrs: network.listenAddrs }),
499
- ...(network?.connectionGater && { connectionGater: network.connectionGater })
848
+ // Configured `listenAddrs`, plus a `/p2p-circuit` listener for every
849
+ // `network.relayAddrs` entry — that listener is what reserves the relay slot.
850
+ ...(listenAddrs && { listenAddrs }),
851
+ // The CONTROL node composes the membership admission gate onto any
852
+ // caller-supplied gater (deny from either wins on inbound; all other
853
+ // hooks pass through). Strand cohort nodes keep the raw configured gater
854
+ // (see strand-instance-manager.ts) — their peers are legitimately
855
+ // cross-party, so cadre membership must not gate them.
856
+ connectionGater: createMembershipConnectionGater({ admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId) }, network?.connectionGater),
857
+ // Fail-closed per-stream authorization for the four Optimystic control-DB
858
+ // protocols — the members-only layer the connection gater's stranger
859
+ // carve-outs cannot express (see authorizeInboundControlStream). Control
860
+ // node only: strand cohort nodes serve cross-party peers.
861
+ authorizeInboundStream: (remotePeerId, protocol) => this.authorizeInboundControlStream(remotePeerId, protocol)
500
862
  };
501
- return await createLibp2pNode(nodeOptions);
863
+ return nodeOptions;
864
+ }
865
+ /**
866
+ * Decide whether an inbound CONTROL-network connection from `remotePeerId`
867
+ * should be admitted — the policy behind the connection gater wired in
868
+ * {@link createControlNode}. Deny only on a positive "unauthorized outsider
869
+ * while no stranger path is open" determination; everything ambiguous admits
870
+ * and defers to the fail-closed per-stream gates (see
871
+ * `membership-connection-gater.ts` for the layer's rationale and the
872
+ * stranger-open protocol allowlist).
873
+ *
874
+ * Admits when ANY of:
875
+ * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
876
+ * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
877
+ * configured bootstrap/relay infrastructure);
878
+ * 2. an enrollment window is open ({@link openEnrollmentWindow}, opened by
879
+ * {@link createInvite}) — the invitee dials in before it is authorized;
880
+ * 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
881
+ * announced it (over the strand-addr RPC) as the transport peerId of its
882
+ * own strand node, so a NAT'd member's strand node can hold a
883
+ * circuit-relay reservation here (see `delegate-admission.ts`).
884
+ * Connection only: the per-stream gate below never honors a grant;
885
+ * 4. the authorized-member set is empty — cold start: the rows that would
886
+ * authorize anyone arrive by replication over these very connections;
887
+ * 5. the peer IS an authorized member; or
888
+ * 6. an open invitation is OUTSTANDING — at least one unexpired,
889
+ * not-fully-consumed invitation this node minted or persisted (see
890
+ * `StrandSolicitationService.hasOutstandingInvitation`). A formation
891
+ * initiator is another party's peer by design and its token is only
892
+ * checkable inside the protocol, so the gate asks the coarser question
893
+ * "does this node expect a stranger at all?". Merely REGISTERING the
894
+ * responder ({@link initializeStrandSolicitation}) no longer suspends
895
+ * stranger denial — eager registration (as `reference-app-rn` does at
896
+ * bring-up) and `formStrand`'s lazy initialization both leave the gate
897
+ * armed, because neither mints an invitation.
898
+ *
899
+ * Ordering is semantically free (the checks are OR'd) but decides who pays:
900
+ * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
901
+ * already on the deny path reaches check 6's invitation lookup.
902
+ *
903
+ * Caveats of check 6, both self-healing:
904
+ * - the in-memory mint registry dies with the process, so after a restart
905
+ * only invitations persisted as `FormationInvite` rows still hold the
906
+ * exemption open (re-mint otherwise — same story as the enrollment window);
907
+ * - a peer holding a token whose `FormationInvite` row has not replicated to
908
+ * this node yet is denied even though the formation handler would have
909
+ * accepted it, exactly like the unreplicated-membership-row case below.
910
+ *
911
+ * NOTE: check 4/5 runs a control-DB read per inbound connection
912
+ * (`listAuthorizedMembers`); connections are rare and cadres small, and this
913
+ * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
914
+ * read is safe here — unlike the per-stream gate, which must consult the
915
+ * materialized {@link authorizedControlPeers} snapshot instead.
916
+ * NOTE: a sibling whose membership row has not yet replicated to
917
+ * this node is denied until the row converges (typically via the owner);
918
+ * either side's next outbound reconcile dial (outbound is never gated)
919
+ * re-establishes the link — self-healing, but visible as a transient deny.
920
+ */
921
+ async admitInboundControlConnection(remotePeerId) {
922
+ if (this.admitControlPeerUnconditionally(remotePeerId)) {
923
+ return true;
924
+ }
925
+ if (Date.now() < this.enrollmentWindowUntil) {
926
+ return true;
927
+ }
928
+ if (this.delegateAdmission.has(remotePeerId)) {
929
+ return true;
930
+ }
931
+ const authorized = await this.listAuthorizedMembers();
932
+ if (authorized.length === 0) {
933
+ return true;
934
+ }
935
+ if (authorized.some((m) => m.peerId === remotePeerId)) {
936
+ return true;
937
+ }
938
+ try {
939
+ if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
940
+ return true;
941
+ }
942
+ }
943
+ catch (error) {
944
+ log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
945
+ return true;
946
+ }
947
+ log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
948
+ return false;
949
+ }
950
+ /**
951
+ * Hold the control-network inbound gate open for not-yet-authorized peers
952
+ * until `untilEpochMs` (extends, never shrinks, an already-open window).
953
+ * {@link createInvite} calls this automatically; a host running an
954
+ * out-of-band enrollment flow (e.g. accepting a phone whose invite this node
955
+ * never minted) can open it explicitly before the stranger dials in.
956
+ */
957
+ openEnrollmentWindow(untilEpochMs) {
958
+ this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
959
+ log('Enrollment window open until %d', this.enrollmentWindowUntil);
960
+ }
961
+ /**
962
+ * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
963
+ * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
964
+ * at the CONNECTION level (all a circuit-relay reservation needs) for
965
+ * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
966
+ * authorized-membership gate has passed; public so tests can drive the
967
+ * admission policy without a full strand launch. A re-announce for the same
968
+ * (announcer, strand) REPLACES the previous delegate rather than
969
+ * accumulating. Never honored by {@link authorizeInboundControlStream}.
970
+ */
971
+ grantDelegateAdmission(announcerPeerId, strandId, delegatePeerId) {
972
+ this.delegateAdmission.grant(announcerPeerId, strandId, delegatePeerId);
973
+ }
974
+ /** Is `remotePeerId` covered by a live delegate admission grant? */
975
+ hasDelegateAdmission(remotePeerId) {
976
+ return this.delegateAdmission.has(remotePeerId);
977
+ }
978
+ /**
979
+ * The admission checks SHARED by the connection gate
980
+ * ({@link admitInboundControlConnection}) and the per-stream control-DB gate
981
+ * ({@link authorizeInboundControlStream}) — factored so the two layers cannot
982
+ * drift. All in-memory and cheap (the stream gate runs this per inbound
983
+ * stream). Returns true when the peer must be admitted BEFORE any membership
984
+ * source is consulted:
985
+ * - the node is not fully up (`start()` in progress / DB torn down) — both
986
+ * gates exist from libp2p bring-up, before the control DB does;
987
+ * - the trusted-owner anchor is absent or empty — an un-enrolled node has
988
+ * no basis to judge anyone and MUST accept its enrollment seed;
989
+ * - the peer is one of the configured control bootstrap/relay nodes —
990
+ * operator-configured infrastructure, not cadre members.
991
+ * False only means "no unconditional admit": the caller then judges the peer
992
+ * against its own membership source (a live DB read for the fail-open
993
+ * connection gate; the materialized snapshot for the fail-closed stream gate).
994
+ */
995
+ admitControlPeerUnconditionally(remotePeerId) {
996
+ if (!this._running || !this.controlDatabase) {
997
+ return true;
998
+ }
999
+ if (!this.trustedOwnerStore || this.trustedOwnerStore.all().size === 0) {
1000
+ return true;
1001
+ }
1002
+ return this.getBootstrapPeerIds().has(remotePeerId);
1003
+ }
1004
+ /**
1005
+ * Per-stream authorization for the four Optimystic control-DB protocols
1006
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`),
1007
+ * wired as `authorizeInboundStream` in {@link createControlNode} — the
1008
+ * fail-closed layer behind the fail-open connection gater. Control node
1009
+ * ONLY: strand cohort nodes legitimately serve cross-party peers.
1010
+ *
1011
+ * The STRICT SUBSET of {@link admitInboundControlConnection}: the same
1012
+ * "no basis to judge" admissions (shared via
1013
+ * {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
1014
+ * (enrollment window, outstanding open invitation, delegate admission
1015
+ * grant). The first two exist so a stranger can reach `/sereus/seed/1.0.0`
1016
+ * and `/sereus/formation/1.0.0` — neither is gated here, and admitting a
1017
+ * stranger to `repo` during an enrollment window is exactly the hole this
1018
+ * gate closes. A DELEGATE-admitted connection (a member's strand node
1019
+ * holding a circuit-relay reservation, see `delegate-admission.ts`) is
1020
+ * likewise exactly a case this gate must still refuse: the delegate gets
1021
+ * the connection and never the control DB.
1022
+ *
1023
+ * Admits when ANY of:
1024
+ * 1. a shared-baseline check admits (not running / no DB, empty anchor,
1025
+ * configured bootstrap infra);
1026
+ * 2. the materialized authorized set is empty — cold start, before the
1027
+ * rows that would authorize anyone have replicated in;
1028
+ * 3. the peer IS in the materialized authorized set.
1029
+ *
1030
+ * Deliberately SYNCHRONOUS and pure in-memory — see
1031
+ * {@link authorizedControlPeers} for why a control-DB read here would
1032
+ * deadlock into mutual denial. With a sync predicate the upstream deadline
1033
+ * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
1034
+ * default.
1035
+ *
1036
+ * NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
1037
+ * key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
1038
+ * siblings deny its control-DB streams once their own snapshot is non-empty —
1039
+ * owner status is a KEY, not a peer id, so being the owner does not exempt it.
1040
+ * Harmless today (a real deployment persists its identity; the ephemeral-owner
1041
+ * case appears only in tests, which configure the owner as bootstrap infra —
1042
+ * `push-wake-e2e.integration.ts` design note 3). If an ephemeral-identity node
1043
+ * ever becomes a supported deployment, this gate needs a key-based admission.
1044
+ */
1045
+ authorizeInboundControlStream(remotePeerId, protocol) {
1046
+ if (this.admitControlPeerUnconditionally(remotePeerId)) {
1047
+ return true;
1048
+ }
1049
+ if (this.authorizedControlPeers.size === 0) {
1050
+ return true;
1051
+ }
1052
+ if (this.authorizedControlPeers.has(remotePeerId)) {
1053
+ return true;
1054
+ }
1055
+ // Upstream logs its own denial line; this one carries the WHY.
1056
+ log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))', remotePeerId, protocol, this.authorizedControlPeers.size);
1057
+ return false;
1058
+ }
1059
+ /**
1060
+ * Refresh {@link authorizedControlPeers} from the control DB, best-effort: a
1061
+ * failed read keeps the previous snapshot (never clears it), so a transient
1062
+ * DB error can neither flip the stream gate's cold-start carve-out back open
1063
+ * nor drop a legitimate member mid-flight. Never rejects.
1064
+ *
1065
+ * The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
1066
+ * so two reads can no longer settle out of order.
1067
+ */
1068
+ async refreshAuthorizedControlPeers(reason) {
1069
+ if (!this._running || !this.controlDatabase) {
1070
+ return;
1071
+ }
1072
+ try {
1073
+ const members = await this.listAuthorizedMembers();
1074
+ this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
1075
+ log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
1076
+ }
1077
+ catch (error) {
1078
+ log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
1079
+ }
1080
+ }
1081
+ /**
1082
+ * PeerIds of the configured control bootstrap nodes (relay/bootstrap
1083
+ * infrastructure — always admitted inbound). Parsed once, lazily; an
1084
+ * address without a `/p2p/<id>` component contributes nothing.
1085
+ */
1086
+ getBootstrapPeerIds() {
1087
+ if (!this.bootstrapPeerIds) {
1088
+ const ids = new Set();
1089
+ for (const addr of this.config.controlNetwork.bootstrapNodes) {
1090
+ try {
1091
+ // Last `/p2p/<id>` component: on a plain bootstrap addr the node's own
1092
+ // id; on a circuit addr the dial target (which is what gets admitted).
1093
+ let id;
1094
+ for (const component of multiaddr(addr).getComponents()) {
1095
+ if (component.name === 'p2p' && component.value) {
1096
+ id = component.value;
1097
+ }
1098
+ }
1099
+ if (id) {
1100
+ ids.add(id);
1101
+ }
1102
+ }
1103
+ catch (error) {
1104
+ log('getBootstrapPeerIds: skipping unparsable bootstrap addr %s: %o', addr, error);
1105
+ }
1106
+ }
1107
+ this.bootstrapPeerIds = ids;
1108
+ }
1109
+ return this.bootstrapPeerIds;
502
1110
  }
503
1111
  createStrandQueryable() {
504
1112
  return {
@@ -515,7 +1123,7 @@ export class CadreNode {
515
1123
  /**
516
1124
  * Schedule the node's initial self-record publish + ongoing refresh shortly
517
1125
  * after start (non-blocking). {@link registerSelf} is idempotent and safely
518
- * no-ops when it cannot yet sign/insert (e.g. authority key not installed),
1126
+ * no-ops when it cannot yet sign/insert (e.g. owner key not installed),
519
1127
  * so the timer is harmless even when registration only becomes possible later.
520
1128
  */
521
1129
  scheduleSelfRegistration() {
@@ -547,10 +1155,14 @@ export class CadreNode {
547
1155
  * (signaling/`p2p-circuit` first), signed with the ed25519 key behind its
548
1156
  * PeerId (the resolved node identity from `keyStore`/`config.privateKey`).
549
1157
  * - If the row already exists: a self-signed UPDATE bumping `UpdatedAt`.
550
- * - If not, and the node is its own authority: an authority-signed INSERT that
551
- * also carries the self-signature.
552
- * - Otherwise: logs and returns (a non-authority node with no row yet must
553
- * wait for an authority to insert it; it can then self-refresh).
1158
+ * - If not, and the node is its own owner: an owner-signed INSERT that
1159
+ * also carries the self-signature. That INSERT is idempotent, so if an owner
1160
+ * {@link authorizePeer} of this node's own id seated the row inside the
1161
+ * read-then-insert window it no-ops — the publish then falls through to the
1162
+ * UPDATE path (re-reading and re-signing against the row that landed) rather
1163
+ * than leaving the authorize's null `Sig` in place, and reports `refreshed`.
1164
+ * - Otherwise: logs and returns (a non-owner node with no row yet must
1165
+ * wait for an owner to insert it; it can then self-refresh).
554
1166
  *
555
1167
  * Safe to call repeatedly (heartbeat / address-change driven); each successful
556
1168
  * publish strictly increases `UpdatedAt`. Concurrent calls are collapsed into a
@@ -589,27 +1201,59 @@ export class CadreNode {
589
1201
  const peerId = this.controlNode.peerId.toString();
590
1202
  const addrs = await this.collectSelfAddrs();
591
1203
  const existing = await this.controlDatabase.queryPeerRecord(peerId);
592
- // Strictly increase UpdatedAt even on a same-millisecond re-publish.
593
- const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
594
- const record = signPeerRecord({ peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
595
- if (existing) {
596
- await this.controlDatabase.updateSelfPeerRecord(record);
597
- if (this.committedAlone())
598
- this.pendingSelfPeerWrite = true;
599
- log('registerSelf: refreshed own CadrePeer record (updatedAt=%d, %d addrs)', updatedAt, addrs.length);
600
- return 'refreshed';
601
- }
602
- if (this.seedBootstrapService) {
603
- // First-time row: requires an authority signature (the node is its own
604
- // authority). insertSelfPeerRecord throws if no authority key is present.
605
- await this.seedBootstrapService.insertSelfPeerRecord(record);
606
- if (this.committedAlone())
607
- this.pendingSelfPeerWrite = true;
608
- log('registerSelf: inserted own CadrePeer record (authority-signed, updatedAt=%d, %d addrs)', updatedAt, addrs.length);
609
- return 'inserted';
1204
+ if (!existing) {
1205
+ if (!this.seedBootstrapService) {
1206
+ log('registerSelf: not yet a CadrePeer member and no owner service to self-insert; skipping (an owner must add this peer first)');
1207
+ return 'skipped';
1208
+ }
1209
+ // First-time row: requires an owner signature (the node is its own
1210
+ // owner). insertSelfPeerRecord throws if no owner key is present.
1211
+ const record = this.signSelfRecord(peerId, signingKey, addrs, null);
1212
+ if (await this.seedBootstrapService.insertSelfPeerRecord(record)) {
1213
+ if (this.committedAlone())
1214
+ this.pendingSelfPeerWrite = true;
1215
+ log('registerSelf: inserted own CadrePeer record (owner-signed, updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
1216
+ return 'inserted';
1217
+ }
1218
+ // An owner authorize of this node's OWN peer id seated the row (null `Sig`)
1219
+ // inside the read-then-insert window, so the idempotent insert no-op'd. Fall
1220
+ // through to the self-update path, which re-signs against the row that landed.
1221
+ //
1222
+ // NOTE: this path reports 'refreshed' — honest about the write, which really was
1223
+ // an UPDATE, though the caller's intent was a first publish. No caller branches on
1224
+ // 'inserted' today (the CLI only logs it); if one ever needs "this was the row's
1225
+ // first publish", add a fourth SelfRegistrationOutcome rather than re-labelling.
1226
+ log('registerSelf: own CadrePeer row appeared mid-publish (concurrent authorize); self-updating to carry the signature');
1227
+ }
1228
+ // Null only on the fall-through above, where the pre-race read missed the row a
1229
+ // concurrent authorize then seated — so re-read to get that row's `UpdatedAt`, which
1230
+ // the strictly-greater self-update rule is measured against.
1231
+ //
1232
+ // NOTE: when `existing` is set it predates this publish, so a removePeer(self) racing
1233
+ // in would leave the UPDATE below matching no rows while still reporting 'refreshed'.
1234
+ // Closing that needs updateSelfPeerRecord to report rows-affected — only worth doing
1235
+ // if removing self ever becomes something that happens concurrently in practice.
1236
+ const current = existing ?? await this.controlDatabase.queryPeerRecord(peerId);
1237
+ if (!current) {
1238
+ log('registerSelf: own CadrePeer row vanished mid-publish; skipping (next refresh re-inserts)');
1239
+ return 'skipped';
610
1240
  }
611
- log('registerSelf: not yet a CadrePeer member and no authority service to self-insert; skipping (an authority must add this peer first)');
612
- return 'skipped';
1241
+ const record = this.signSelfRecord(peerId, signingKey, addrs, current);
1242
+ await this.controlDatabase.updateSelfPeerRecord(record);
1243
+ if (this.committedAlone())
1244
+ this.pendingSelfPeerWrite = true;
1245
+ log('registerSelf: refreshed own CadrePeer record (updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
1246
+ return 'refreshed';
1247
+ }
1248
+ /**
1249
+ * Sign this node's address record for publication, stamped strictly later than
1250
+ * `existing` (the row it is about to replace, or null for a first insert) so a
1251
+ * same-millisecond re-publish still satisfies the monotonic `UpdatedAt` rule the
1252
+ * `CadrePeer.AuthorizedUpdate` self-branch enforces.
1253
+ */
1254
+ signSelfRecord(peerId, signingKey, addrs, existing) {
1255
+ const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
1256
+ return signPeerRecord({ peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
613
1257
  }
614
1258
  /**
615
1259
  * The ed25519 keypair (base64url) the node signs its own record with — the key
@@ -625,41 +1269,41 @@ export class CadreNode {
625
1269
  return null;
626
1270
  }
627
1271
  try {
628
- const { privateKeyB64, publicKeyB64 } = authorityKeyFromLibp2p(this.identityKey);
1272
+ const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(this.identityKey);
629
1273
  if (publicKeyB64 === ed25519PublicKeyB64FromPeerId(peerId)) {
630
1274
  return { privateKeyB64, publicKeyB64 };
631
1275
  }
632
1276
  log('getSelfSigningKey: resolved identity key does not match control node peerId; cannot self-sign');
633
1277
  }
634
1278
  catch (error) {
635
- // Logs the error shape only; authorityKeyFromLibp2p never embeds key material.
1279
+ // Logs the error shape only; ed25519KeyPairFromLibp2p never embeds key material.
636
1280
  log('getSelfSigningKey: failed to derive ed25519 key from identity key: %o', error);
637
1281
  }
638
1282
  return null;
639
1283
  }
640
1284
  /**
641
- * The authority keypair (base64url Ed25519) derived from this node's resolved
642
- * identity key. In the single-key reference model the authority signing key is
643
- * *derived from* the node identity (see {@link authorityKeyFromLibp2p}), so the
1285
+ * The owner keypair (base64url Ed25519) derived from this node's resolved
1286
+ * identity key. In the single-key reference model the owner signing key is
1287
+ * *derived from* the node identity (see {@link ed25519KeyPairFromLibp2p}), so the
644
1288
  * same key material protected in a secure enclave backs both.
645
1289
  *
646
- * Exposed so the hosting app retains control of authority genesis: cadre-core
1290
+ * Exposed so the hosting app retains control of owner genesis: cadre-core
647
1291
  * resolves + protects the identity, then the app sources this pair to drive
648
- * `ensureAuthorityKey(pub)` + `initializeSeedBootstrap(priv)` itself — cadre-core
649
- * never silently runs genesis. A future separate-authority slot would return a
1292
+ * `ensureOwnerKey(pub)` + `initializeSeedBootstrap(priv)` itself — cadre-core
1293
+ * never silently runs genesis. A future separate-owner slot would return a
650
1294
  * distinct key here instead of the identity-derived one.
651
1295
  *
652
- * @returns The base64url seed/public-key authority pair.
1296
+ * @returns The base64url seed/public-key owner pair.
653
1297
  * @throws If called before {@link start} has resolved the identity, or when the
654
1298
  * node runs on an ephemeral libp2p key (no `keyStore`/`privateKey` configured),
655
1299
  * since that key is internal to libp2p and not exposed.
656
1300
  */
657
- getIdentityAuthorityKey() {
1301
+ getIdentityOwnerKey() {
658
1302
  if (!this.identityKey) {
659
- throw new Error('getIdentityAuthorityKey: node identity not resolved — call start() first, and ' +
660
- 'configure `keyStore` or `privateKey` (an ephemeral libp2p identity exposes no authority key)');
1303
+ throw new Error('getIdentityOwnerKey: node identity not resolved — call start() first, and ' +
1304
+ 'configure `keyStore` or `privateKey` (an ephemeral libp2p identity exposes no owner key)');
661
1305
  }
662
- return authorityKeyFromLibp2p(this.identityKey);
1306
+ return ed25519KeyPairFromLibp2p(this.identityKey);
663
1307
  }
664
1308
  /**
665
1309
  * Collect this node's current dialable addresses for publication, signaling
@@ -741,13 +1385,17 @@ export class CadreNode {
741
1385
  this.turnConnectionOpenHandler = null;
742
1386
  this.turnConnectionCloseHandler = null;
743
1387
  // Reset the write-while-alone re-replication state so a stop()→start() cycle
744
- // re-arms the growth edge and re-runs the first-growth reconstruction. Any
745
- // queued local-only writes are moot once the node is torn down; authority
746
- // inserts are re-covered by reconstruction on the next start, and the
747
- // (already-loudly-logged) delete gap is tracked for follow-up.
1388
+ // re-arms the growth edge and re-runs BOTH one-shot passes. Any queued
1389
+ // local-only writes are moot once the node is torn down: owner inserts are
1390
+ // re-covered by the next start's reconstruction, and delete tombstones by its
1391
+ // first-growth revocation sweep — which is why the sweep flag must reset too,
1392
+ // or the second lifetime would skip the sweep and strand exactly the
1393
+ // committed-alone tombstones it exists to carry.
748
1394
  this.hasControlConnection = false;
749
1395
  this.reconstructedLocalOnlyWrites = false;
1396
+ this.reissuedHeldRevocations = false;
750
1397
  this.pendingPeerWrites.clear();
1398
+ this.pendingRevocations.clear();
751
1399
  this.pendingSelfPeerWrite = false;
752
1400
  this.pendingSelfDeviceWrite = false;
753
1401
  }
@@ -785,7 +1433,10 @@ export class CadreNode {
785
1433
  }
786
1434
  // Self-signature over (peerId, addrs, updatedAt).
787
1435
  if (!verifyPeerRecordSignature(record)) {
788
- log('resolvePeerAddrs: signature verification failed for %s', peerId);
1436
+ // Field detail distinguishes "holding the owner-vouch revision (Sig null,
1437
+ // no addrs — a not-yet-self-published row)" from a genuinely corrupt or
1438
+ // mixed-revision row. Sig/addrs are public replicated data.
1439
+ log('resolvePeerAddrs: signature verification failed for %s (updatedAt=%d, addrs=%o, sig=%s)', peerId, record.updatedAt, record.addrs, record.sig ? `${record.sig.slice(0, 16)}…` : '(empty)');
789
1440
  return [];
790
1441
  }
791
1442
  // Freshness: never hand back a dead relay reservation.
@@ -874,32 +1525,102 @@ export class CadreNode {
874
1525
  if (!this._running || !this.controlNode || !this.controlDatabase) {
875
1526
  return;
876
1527
  }
1528
+ // Ride this pass's cadence to refresh the per-stream gate's materialized
1529
+ // authorized set — membership changes that ARRIVED BY REPLICATION (rather
1530
+ // than a local write) are picked up here, bounding the snapshot's staleness
1531
+ // to the reconcile interval.
1532
+ // NOTE: this refresh and the sibling enumeration below each run their own
1533
+ // CadrePeer query (two reads per pass); if those reads ever get costly,
1534
+ // share one row-set across both.
1535
+ await this.refreshMembershipGate('reconcile');
1536
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
1537
+ return;
1538
+ }
1539
+ // Refresh relay delegate grants BEFORE the sibling enumeration below: a
1540
+ // solo cadre with a party relay has no siblings to dial but must still keep
1541
+ // its running strands' grants alive (a dropped relay connection re-dials
1542
+ // the reservation and faces the connection gate again).
1543
+ await this.refreshDelegateGrants();
1544
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
1545
+ return;
1546
+ }
877
1547
  const selfPeerId = this.controlNode.peerId.toString();
1548
+ // Reap guarded rows this node still holds for incarnations an already-committed
1549
+ // Revocation tombstone retires — the half of a removal replication cannot carry
1550
+ // (a delete of an already-absent row replays as nothing). Connected-only: a reap
1551
+ // is a write, and a write committed alone is local-only and forks this node's own
1552
+ // history, which is the condition this work exists to stop creating
1553
+ // (tickets/blocked/forked-control-collection-sync-livelocks.md). Nothing is urgent
1554
+ // here — the stale row already reads as absent everywhere — so waiting for
1555
+ // connectivity costs nothing and keeps the reap fork-CLOSING rather than
1556
+ // fork-creating.
1557
+ //
1558
+ // BEFORE step 1, not after: the pass early-returns when it finds no siblings, and a
1559
+ // cadre whose only sibling row is the tombstoned one lands in exactly that branch
1560
+ // (listMembers() filters retired stamps, so the row needing the reap is invisible to
1561
+ // the sibling count). Placed after step 1 it would never run for the smallest and
1562
+ // most likely case.
1563
+ //
1564
+ // Shares no mutable state with drainPendingRevocations, which re-issues tombstones
1565
+ // while this consumes them: different tables (Revocation update vs guarded-row
1566
+ // delete) and both take the write lock per statement. The two can overlap freely —
1567
+ // do not add a mutex.
1568
+ //
1569
+ // NOTE: the gate is sampled ONCE for the whole sweep, so a disconnect landing
1570
+ // mid-sweep lets the remaining rows commit alone — the fork this gate exists to
1571
+ // avoid. Bounded by how long a sweep runs, which is bounded by the backlog of
1572
+ // unreaped rows (0 in steady state). If that backlog can ever be large, move the
1573
+ // check inside reapRevokedRows' loop via an injected predicate.
1574
+ if (this.getControlConnectionCount() > 0) {
1575
+ try {
1576
+ const reaped = await this.controlDatabase.reapRevokedRows(selfPeerId);
1577
+ if (reaped > 0) {
1578
+ log('reconcileControlCohort: reaped %d revoked control row(s)', reaped);
1579
+ }
1580
+ }
1581
+ catch (error) {
1582
+ // Best-effort like every other step in this pass: reconnecting siblings
1583
+ // outranks garbage collection, so a reap failure never aborts the reconcile.
1584
+ log('reconcileControlCohort: reap pass failed (continuing): %o', error);
1585
+ }
1586
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
1587
+ return;
1588
+ }
1589
+ }
878
1590
  // 1. Enumerate known siblings. This membership read is itself a pull-on-read
879
1591
  // that helps the CadrePeer table converge — a reader-only node converges
880
1592
  // purely by these reads.
881
1593
  const members = await this.listMembers();
882
1594
  const siblings = members.filter((m) => m.peerId !== selfPeerId);
883
1595
  if (siblings.length === 0) {
884
- // Genuinely alone (cold start with no rows, or a solo cadre): nothing to
885
- // dial. Must not throw or busy-loop.
1596
+ // No rows to dial from. Either a solo cadre (nothing to do) or a COLD
1597
+ // START whose seed dial never landed — and those look identical from here,
1598
+ // because filling this table needs a connection and getting a connection
1599
+ // needs this table. Fall back to the seed's bootstrap addresses; a solo
1600
+ // cadre has none and this is a no-op. Must not throw or busy-loop.
1601
+ await this.dialColdStartBootstrap();
886
1602
  return;
887
1603
  }
888
1604
  // Re-guard after the await: a stop() may have raced the membership read.
889
1605
  if (!this._running || !this.controlNode || !this.controlDatabase) {
890
1606
  return;
891
1607
  }
892
- // 2. Classify backbone (authority) members and select a bounded dial set.
893
- const authorityKeys = await this.controlDatabase.getAuthorityKeys();
1608
+ // 2. Classify backbone (owner) members and select a bounded dial set.
1609
+ // NOTE: deliberately the REPLICATED table, not the node-local anchor — this
1610
+ // only *prefers* owner peers as dial targets, so pollution costs a wasted
1611
+ // dial while anchoring would drop legitimate co-owners this node never
1612
+ // pinned. Same call as `SeedPeer.isOwner` in seed-bootstrap.ts. Move it to
1613
+ // the anchor if owner status here ever gates something trusted.
1614
+ const ownerKeys = await this.controlDatabase.getOwnerKeys();
894
1615
  if (!this._running || !this.controlNode) {
895
1616
  return;
896
1617
  }
897
1618
  const targetDegree = this.config.network?.controlCohort?.targetDegree
898
1619
  ?? DEFAULT_CONTROL_COHORT_TARGET_DEGREE;
899
- const { dials, cappedNonAuthority } = selectControlCohortDials(siblings, authorityKeys, targetDegree);
900
- if (cappedNonAuthority > 0) {
1620
+ const { dials, cappedNonOwner } = selectControlCohortDials(siblings, ownerKeys, targetDegree);
1621
+ if (cappedNonOwner > 0) {
901
1622
  // Don't silently bound coverage — surface what the out-degree cap dropped.
902
- log('reconcileControlCohort: capped %d non-authority sibling(s) at targetDegree=%d', cappedNonAuthority, targetDegree);
1623
+ log('reconcileControlCohort: capped %d non-owner sibling(s) at targetDegree=%d', cappedNonOwner, targetDegree);
903
1624
  }
904
1625
  // 3. Skip already-connected peers (no re-dial / churn for live connections).
905
1626
  const connected = new Set(this.controlNode.getConnections().map((c) => c.remotePeer.toString()));
@@ -924,7 +1645,7 @@ export class CadreNode {
924
1645
  *
925
1646
  * A per-peer failure (NAT, offline, relay down, connection-gater denial) is
926
1647
  * logged and swallowed so one unreachable sibling never aborts the pass —
927
- * exactly like {@link SeedBootstrapService.applySeed}'s authority-dial loop. A
1648
+ * exactly like {@link SeedBootstrapService.applySeed}'s owner-dial loop. A
928
1649
  * failed dial is simply retried on the next pass.
929
1650
  */
930
1651
  async dialControlSibling(sibling) {
@@ -983,11 +1704,154 @@ export class CadreNode {
983
1704
  return [];
984
1705
  }
985
1706
  }
1707
+ /**
1708
+ * Retain a just-applied seed's owner-flagged peers as cold-start bootstrap
1709
+ * dial targets (see {@link bootstrapPeerStore}).
1710
+ *
1711
+ * Called for every seed this node accepts, on BOTH intake paths — the
1712
+ * {@link applySeed} wrapper (which may run on a throwaway service, so the
1713
+ * service itself is the wrong place to keep this) and the inbound
1714
+ * `/sereus/seed/1.0.0` handler via `onSeedApplied`. Peers with no address are
1715
+ * skipped: there is nothing to dial.
1716
+ *
1717
+ * `isOwner` is the seed's own claim about its peers, exactly as
1718
+ * `SeedBootstrapService.applySeed` uses it to choose its dial targets. That is
1719
+ * sound here for the same reason it is sound there — the whole seed is
1720
+ * signature-checked against a trust-anchored signer before this runs, and the
1721
+ * flag only *selects a dial target*; a dial grants no authority.
1722
+ *
1723
+ * Self is excluded. `createSeed` projects EVERY `CadrePeer` row, so an owner
1724
+ * that applies a seed minted after it joined finds itself in the owner list;
1725
+ * retaining that would make the cold-start pass dial this node forever (the
1726
+ * steady-state pass filters self out of its sibling list for the same reason).
1727
+ */
1728
+ recordSeedBootstrapPeers(seed) {
1729
+ const store = this.bootstrapPeerStore;
1730
+ if (!store) {
1731
+ // Unreachable in production: start() builds the store before any network
1732
+ // bring-up, and both intake paths need a started node (an uninitialized
1733
+ // SeedBootstrapService rejects the seed, so noteAppliedSeed returns early).
1734
+ log('recordSeedBootstrapPeers: no bootstrap-peer store yet; retaining nothing');
1735
+ return;
1736
+ }
1737
+ const selfPeerId = this.controlNode?.peerId.toString();
1738
+ for (const peer of seed.peers) {
1739
+ if (!peer.isOwner || peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
1740
+ continue;
1741
+ }
1742
+ // Sync-visible by contract; the promise tracks durability only. A persist
1743
+ // failure costs restart survival, never this session's retry set — the same
1744
+ // trade `SeedBootstrapService.anchorAcceptedSigner` makes — so log and carry
1745
+ // on rather than failing the seed that was already accepted.
1746
+ void store.record(peer.peerId, peer.multiaddrs).catch((error) => {
1747
+ log('recordSeedBootstrapPeers: persisting bootstrap peer %s failed (retained in memory): %o', peer.peerId, error);
1748
+ });
1749
+ }
1750
+ }
1751
+ /**
1752
+ * Cold-start branch of the reconcile pass: re-dial the seed's owner peers
1753
+ * while the control database still has no siblings to dial.
1754
+ *
1755
+ * `SeedBootstrapService.applySeed` dials those owners exactly ONCE, best-effort.
1756
+ * When that single dial fails — owner momentarily down, relay reservation not
1757
+ * up yet, NAT traversal lost the race — the joining node has an empty
1758
+ * `CadrePeer` table and no connection, and the steady-state pass above cannot
1759
+ * help: it dials only siblings enumerated from that very table. Retrying here
1760
+ * is the only way back in.
1761
+ *
1762
+ * Unbounded, at the reconcile cadence, with no backoff: the branch is already
1763
+ * gated by "the control database has no siblings", so it stops the moment the
1764
+ * node is actually in the party, and a node that is stranded MUST keep trying —
1765
+ * a give-up rule would turn a transient outage into a permanent one. The steady
1766
+ * cost is one dial per bootstrap peer per pass (seeds nominate one or a few
1767
+ * owners), each failing fast against an unreachable address.
1768
+ * NOTE: if seeds ever carry many owner peers, or the reconcile cadence tightens
1769
+ * well below its 15 s default, add per-peer backoff here.
1770
+ *
1771
+ * Best-effort per peer, exactly like {@link dialControlSibling}: one dead
1772
+ * address never aborts the pass.
1773
+ */
1774
+ async dialColdStartBootstrap() {
1775
+ const controlNode = this.controlNode;
1776
+ // Snapshot up front: the store hands back a copy, so a seed applied mid-pass
1777
+ // cannot mutate what we are iterating.
1778
+ const targets = this.bootstrapPeerStore?.all();
1779
+ if (!controlNode || !targets || targets.size === 0) {
1780
+ return;
1781
+ }
1782
+ // Same skip rule as step 3 of the steady-state pass: no churn on live links.
1783
+ const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
1784
+ let dialed = 0;
1785
+ for (const [peerId, entry] of targets) {
1786
+ if (!this._running || !this.controlNode) {
1787
+ return;
1788
+ }
1789
+ if (connected.has(peerId)) {
1790
+ continue;
1791
+ }
1792
+ if (await this.dialBootstrapPeer(peerId, entry.addrs)) {
1793
+ dialed++;
1794
+ }
1795
+ }
1796
+ log('reconcileControlCohort: cold-start pass complete (bootstrap=%d, connected=%d, dialed=%d)', targets.size, connected.size, dialed);
1797
+ }
1798
+ /**
1799
+ * Bind a bootstrap peer's seed addresses to its peer id, so the dial
1800
+ * authenticates the peer it is aiming at rather than trusting whoever answers.
1801
+ *
1802
+ * An address that already carries `/p2p/<id>` must carry THIS peer's id or it
1803
+ * is dropped (a seed that disagrees with itself is not a dial target); one
1804
+ * that carries none — an `addressOverrides`-published record, say — gets the
1805
+ * id encapsulated. Unencapsulatable addresses are dropped rather than dialed
1806
+ * bare.
1807
+ */
1808
+ bootstrapDialAddrs(peerId, addrs) {
1809
+ const out = [];
1810
+ for (const addr of this.parseMultiaddrs(addrs)) {
1811
+ const embedded = trailingPeerId(addr);
1812
+ if (embedded === peerId) {
1813
+ out.push(addr);
1814
+ continue;
1815
+ }
1816
+ if (embedded !== null) {
1817
+ log('reconcileControlCohort(cold-start): addr %s names peer %s, not %s; dropping', addr.toString(), embedded, peerId);
1818
+ continue;
1819
+ }
1820
+ try {
1821
+ out.push(addr.encapsulate(`/p2p/${peerId}`));
1822
+ }
1823
+ catch (error) {
1824
+ log('reconcileControlCohort(cold-start): cannot bind %s to %s: %o', addr.toString(), peerId, error);
1825
+ }
1826
+ }
1827
+ return out;
1828
+ }
1829
+ /** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
1830
+ async dialBootstrapPeer(peerId, addrs) {
1831
+ const controlNode = this.controlNode;
1832
+ if (!controlNode) {
1833
+ return false;
1834
+ }
1835
+ const parsed = this.bootstrapDialAddrs(peerId, addrs);
1836
+ if (parsed.length === 0) {
1837
+ log('reconcileControlCohort(cold-start): no dialable address for bootstrap peer %s; skipping', peerId);
1838
+ return false;
1839
+ }
1840
+ try {
1841
+ log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
1842
+ await controlNode.dial(parsed);
1843
+ return true;
1844
+ }
1845
+ catch (error) {
1846
+ log('reconcileControlCohort(cold-start): dial of bootstrap peer %s failed (continuing): %o', peerId, error);
1847
+ return false;
1848
+ }
1849
+ }
986
1850
  // ============================================================================
987
1851
  // Write-while-alone re-replication (control-write-ensure-replicated)
988
1852
  //
989
1853
  // Optimystic's coordinator commits a control write WITHOUT broadcasting when the
990
- // block's cluster has ≤1 member. An authority that authorizes/removes a peer or
1854
+ // block's cluster has ≤1 member. An owner that authorizes/removes a peer or
991
1855
  // (re)publishes its own record while no sibling is connected therefore writes a
992
1856
  // row that exists ONLY in its local control DB — a sibling that connects later
993
1857
  // never observes it (pull-on-read routes to the block's cluster, which never
@@ -1007,38 +1871,61 @@ export class CadreNode {
1007
1871
  * is caught by the cohort's periodic pull-on-read instead. Re-issuing an already-
1008
1872
  * replicated row is harmless (an idempotent monotonic bump), so the coarse proxy
1009
1873
  * is acceptable as the agreed first cut.
1874
+ *
1875
+ * Reads {@link getControlConnectionCount} — the public, embedder-facing form of
1876
+ * the same sample — so the two cannot drift.
1010
1877
  */
1011
1878
  committedAlone() {
1012
- return (this.controlNode?.getConnections().length ?? 0) === 0;
1879
+ return this.getControlConnectionCount() === 0;
1013
1880
  }
1014
1881
  /**
1015
- * Record (or clear) a just-committed authority membership write in the
1882
+ * Record (or clear) a just-committed owner membership write in the
1016
1883
  * write-while-alone re-replication queue. If the control node had no connections
1017
1884
  * at commit, the Optimystic write was local-only and must be re-issued once the
1018
1885
  * cohort grows; otherwise the write replicated and any stale queue entry for this
1019
1886
  * subject is dropped.
1020
1887
  *
1021
- * A `remove` that commits alone is a security-relevant durability gap (a revoked
1022
- * peer may persist as a member elsewhere), so it is logged loudly — and, because
1023
- * a physical delete leaves no local trace to re-issue from, its on-growth
1024
- * re-issue is best-effort only (see {@link drainPendingPeerWrites}).
1888
+ * A `remove` never queues here — a delete's re-replicable half is its
1889
+ * `Revocation` tombstone, which the committed-delete seam routes into
1890
+ * {@link pendingRevocations} ({@link noteGuardedDelete}). The `remove` arm only
1891
+ * drops any queued authorize for the subject (the row is gone; re-issuing the
1892
+ * insert would resurrect it) and, when the commit was alone, logs the
1893
+ * security-relevant window loudly.
1025
1894
  */
1026
1895
  noteControlWrite(peerId, kind) {
1027
- if (!this.committedAlone()) {
1896
+ if (kind === 'remove') {
1028
1897
  this.pendingPeerWrites.delete(peerId);
1898
+ if (this.committedAlone()) {
1899
+ log('removePeer(%s) committed while ALONE (0 control connections): the deletion is ' +
1900
+ 'local-only, so a revoked peer may persist in the cohort until the queued ' +
1901
+ 'Revocation tombstone is re-issued on cohort growth (drainPendingRevocations).', peerId);
1902
+ }
1029
1903
  return;
1030
1904
  }
1031
- this.pendingPeerWrites.set(peerId, kind);
1032
- if (kind === 'remove') {
1033
- log('removePeer(%s) committed while ALONE (0 control connections): the deletion is ' +
1034
- 'local-only, so a revoked peer may persist in the cohort until re-replication. The ' +
1035
- 'on-growth re-issue is best-effort (a physical delete cannot be replayed without a ' +
1036
- 'schema tombstone — see control-delete-while-alone-tombstone).', peerId);
1905
+ if (!this.committedAlone()) {
1906
+ this.pendingPeerWrites.delete(peerId);
1907
+ return;
1037
1908
  }
1038
- else {
1039
- log('authorizePeer(%s) committed while alone (0 control connections); queued for ' +
1040
- 're-replication on cohort growth', peerId);
1909
+ this.pendingPeerWrites.set(peerId, 'authorize');
1910
+ log('authorizePeer(%s) committed while alone (0 control connections); queued for ' +
1911
+ 're-replication on cohort growth', peerId);
1912
+ }
1913
+ /**
1914
+ * Committed-delete seam handler ({@link GuardedDeleteListener}, wired in
1915
+ * {@link start}): a guarded-table delete and its `Revocation` tombstone
1916
+ * committed. If the node was alone the tombstone is local-only — queue it for
1917
+ * an owner-signed re-issue on cohort growth; otherwise it broadcast, so drop
1918
+ * any stale queue entry for the stamp. Synchronous by contract — bookkeeping
1919
+ * only, never throws into the delete path.
1920
+ */
1921
+ noteGuardedDelete(revocation) {
1922
+ if (!this.committedAlone()) {
1923
+ this.pendingRevocations.delete(revocation.stampId);
1924
+ return;
1041
1925
  }
1926
+ this.pendingRevocations.set(revocation.stampId, revocation);
1927
+ log('%s delete of %s committed while alone (0 control connections); Revocation tombstone ' +
1928
+ '(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
1042
1929
  }
1043
1930
  /**
1044
1931
  * Drain the write-while-alone re-replication queue: re-issue the writes that
@@ -1069,8 +1956,19 @@ export class CadreNode {
1069
1956
  return;
1070
1957
  }
1071
1958
  const firstGrowth = !this.reconstructedLocalOnlyWrites;
1072
- log('Draining control re-replication (reason=%s, firstGrowth=%s, pendingPeers=%d, self=%s, deviceToken=%s)', reason, firstGrowth, this.pendingPeerWrites.size, this.pendingSelfPeerWrite, this.pendingSelfDeviceWrite);
1073
- // 1. Self rows. On the first growth always re-touch (covers a self row carried
1959
+ log('Draining control re-replication (reason=%s, firstGrowth=%s, pendingPeers=%d, pendingRevocations=%d, self=%s, deviceToken=%s)', reason, firstGrowth, this.pendingPeerWrites.size, this.pendingRevocations.size, this.pendingSelfPeerWrite, this.pendingSelfDeviceWrite);
1960
+ // 1. Revocation tombstones FIRST: a removal that never reached the cohort
1961
+ // leaves a revoked peer live elsewhere, which outranks every re-touch
1962
+ // below. Ordering is not cosmetic — every `CadrePeer` write here (self row
1963
+ // included) can burn tens of seconds in upstream retry livelock after a
1964
+ // collection fork, while the tombstone lives in a different collection
1965
+ // that is merely behind, not forked. No membership-gate refresh around
1966
+ // this step: a re-issue only bumps `ReissuedAt` — LOCAL membership
1967
+ // visibility changed at delete time (deleteCadrePeer already notified),
1968
+ // and receiving-node staleness is the pre-existing pull-on-read /
1969
+ // periodic-refresh property.
1970
+ await this.drainPendingRevocations();
1971
+ // 2. Self rows. On the first growth always re-touch (covers a self row carried
1074
1972
  // over from a prior process that committed local-only before this start);
1075
1973
  // afterwards only when a self-write-while-alone was recorded this session.
1076
1974
  // registerSelf is idempotent + single-flight, so this never races a
@@ -1083,15 +1981,15 @@ export class CadreNode {
1083
1981
  await this.retouchSelfDeviceToken();
1084
1982
  this.pendingSelfDeviceWrite = false;
1085
1983
  }
1086
- // 2. First growth: an authority reconstructs membership rows it may have
1984
+ // 3. First growth: an owner reconstructs membership rows it may have
1087
1985
  // authored that never replicated (covers writes from before this process
1088
1986
  // started). Rows already tracked in the in-memory queue are handled by
1089
- // step 3 — skipped here to avoid a double re-touch.
1987
+ // step 4 — skipped here to avoid a double re-touch.
1090
1988
  if (firstGrowth) {
1091
1989
  this.reconstructedLocalOnlyWrites = true;
1092
1990
  await this.reconstructAuthoredMembership();
1093
1991
  }
1094
- // 3. In-session pending authority membership writes (authorize / remove).
1992
+ // 4. In-session pending owner authorize writes.
1095
1993
  await this.drainPendingPeerWrites();
1096
1994
  }
1097
1995
  /**
@@ -1117,21 +2015,85 @@ export class CadreNode {
1117
2015
  log('drain: retouch self DeviceToken failed: %o', error);
1118
2016
  }
1119
2017
  }
2018
+ /**
2019
+ * Re-issue `Revocation` tombstones so a removal that committed while alone
2020
+ * still reaches the cohort. Two regimes, one method:
2021
+ *
2022
+ * - **First successful pass per process** ({@link reissuedHeldRevocations}
2023
+ * false): sweep EVERY locally-held tombstone from `queryRevocations()` — the
2024
+ * only cover for a removal made before this process started, since the
2025
+ * in-memory queue does not survive a restart. Queued stamps are part of that
2026
+ * same batch (exactly once — one transaction, no double bump).
2027
+ * - **Later passes**: only the tombstones queued in-session by
2028
+ * {@link noteGuardedDelete}.
2029
+ *
2030
+ * Owner-gated: a node with no owner key cannot sign a re-issue, so stray queue
2031
+ * entries are dropped (mirrors {@link drainPendingPeerWrites}). Best-effort — a
2032
+ * failure is logged and leaves the queue (and the sweep flag) untouched for the
2033
+ * next growth edge. A successful `reissueRevocations` exec is NOT proof of
2034
+ * broadcast (the connection that fired the growth edge may not be in the
2035
+ * affected block's cluster — see
2036
+ * tickets/backlog/control-rereplication-broadcast-confirmation); the drain
2037
+ * inherits that known gap, and a full disconnect→reconnect (or the next
2038
+ * process's sweep) re-covers it.
2039
+ *
2040
+ * NOTE: the sweep is O(all tombstones ever) row-updates — plus one owner
2041
+ * signature each — in one transaction, once per lifetime. `Revocation` is
2042
+ * append-only and unbounded growth is declared acceptable for a cadre-sized
2043
+ * party. If the tombstone table ever gets large, bound the sweep (e.g. persist
2044
+ * a node-local high-water mark of what has been re-issued while connected)
2045
+ * instead of re-touching everything; note the `Math.max(...)` spread below also
2046
+ * caps out around 10^5 rows (V8 argument limit) before the size becomes merely
2047
+ * a latency problem.
2048
+ */
2049
+ async drainPendingRevocations() {
2050
+ const sweep = !this.reissuedHeldRevocations;
2051
+ if (!sweep && this.pendingRevocations.size === 0) {
2052
+ return;
2053
+ }
2054
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
2055
+ return;
2056
+ }
2057
+ if (!this.seedBootstrapService?.canAuthorize()) {
2058
+ // A non-owner node cannot sign a re-issue; drop any stray entries.
2059
+ this.pendingRevocations.clear();
2060
+ return;
2061
+ }
2062
+ try {
2063
+ const held = await this.controlDatabase.queryRevocations();
2064
+ const rows = sweep ? held : held.filter((r) => this.pendingRevocations.has(r.stampId));
2065
+ if (rows.length > 0) {
2066
+ const reissuedAt = Math.max(Date.now(), Math.max(...rows.map((r) => r.reissuedAt)) + 1);
2067
+ await this.seedBootstrapService.reissueRevocations(rows, reissuedAt);
2068
+ log('drain: re-issued %d revocation tombstone(s)%s', rows.length, sweep ? ' (first-growth sweep)' : '');
2069
+ }
2070
+ this.reissuedHeldRevocations = true;
2071
+ // Per-stamp clear (not clear()) so an entry queued concurrently mid-drain
2072
+ // survives for the next growth edge.
2073
+ for (const row of rows) {
2074
+ this.pendingRevocations.delete(row.stampId);
2075
+ }
2076
+ }
2077
+ catch (error) {
2078
+ log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
2079
+ }
2080
+ }
1120
2081
  /**
1121
2082
  * One-shot, first-cohort-growth reconstruction of the write-while-alone queue for
1122
- * an AUTHORITY node: re-touch every membership row that may be an unreplicated
1123
- * authority insert. A row is a candidate iff it is not self (handled by
2083
+ * an OWNER node: re-touch every membership row that may be an unreplicated
2084
+ * owner insert. A row is a candidate iff it is not self (handled by
1124
2085
  * {@link registerSelf}), is not already tracked in the in-memory queue (handled by
1125
2086
  * {@link drainPendingPeerWrites}), and carries no self-`Sig` yet (an
1126
- * authority-authored row the peer has not self-published — the only kind safe to
2087
+ * owner-authored row the peer has not self-published — the only kind safe to
1127
2088
  * bump without invalidating a self-signature). O(rows) on the small control
1128
- * tables; safe to over-apply (a monotonic authority bump on an already-replicated
2089
+ * tables; safe to over-apply (a monotonic owner bump on an already-replicated
1129
2090
  * row is a no-op-equivalent).
1130
2091
  *
1131
- * A node with no authority private key skips this entirely — it cannot re-sign
1132
- * rows for other peers, and rows it merely holds are not its to re-issue. DELETEs
1133
- * cannot be reconstructed here (a removed row leaves no local trace and the schema
1134
- * carries no tombstone) — see the delete-path note in {@link drainPendingPeerWrites}.
2092
+ * A node with no owner private key skips this entirely — it cannot re-sign
2093
+ * rows for other peers, and rows it merely holds are not its to re-issue.
2094
+ * DELETEs are not reconstructed here — the `Revocation` tombstone sweep in
2095
+ * {@link drainPendingRevocations} covers them (a removed row leaves no
2096
+ * `CadrePeer` trace, but its tombstone is re-issuable).
1135
2097
  */
1136
2098
  async reconstructAuthoredMembership() {
1137
2099
  if (!this.controlDatabase || !this.controlNode) {
@@ -1142,10 +2104,25 @@ export class CadreNode {
1142
2104
  }
1143
2105
  const selfPeerId = this.controlNode.peerId.toString();
1144
2106
  const members = await this.controlDatabase.queryCadrePeers();
2107
+ // One gate refresh for the whole sweep: every re-touch is a notifying
2108
+ // `CadrePeer` write, and these rows are already in the local snapshot (they
2109
+ // committed here, just local-only), so per-row refreshes would be O(rows)
2110
+ // membership reads for a snapshot that does not change.
2111
+ const touched = await this.deferMembershipGateRefresh('drain-reconstruct', () => this.reissueAuthoredMembershipRows(selfPeerId, members));
2112
+ if (touched > 0) {
2113
+ log('drain: reconstructed %d owner-authored membership row(s) on first cohort growth', touched);
2114
+ }
2115
+ }
2116
+ /**
2117
+ * Body of {@link reconstructAuthoredMembership}'s sweep: re-issue each candidate
2118
+ * owner-authored row, returning how many were re-touched. A per-row failure is
2119
+ * logged and skipped; a teardown mid-sweep stops it.
2120
+ */
2121
+ async reissueAuthoredMembershipRows(selfPeerId, members) {
1145
2122
  let touched = 0;
1146
2123
  for (const member of members) {
1147
2124
  if (!this._running || !this.controlNode || !this.controlDatabase) {
1148
- return;
2125
+ return touched;
1149
2126
  }
1150
2127
  if (member.peerId === selfPeerId || this.pendingPeerWrites.has(member.peerId)) {
1151
2128
  continue;
@@ -1163,66 +2140,62 @@ export class CadreNode {
1163
2140
  log('drain: reconstruction re-touch of %s failed (continuing): %o', member.peerId, error);
1164
2141
  }
1165
2142
  }
1166
- if (touched > 0) {
1167
- log('drain: reconstructed %d authority-authored membership row(s) on first cohort growth', touched);
1168
- }
2143
+ return touched;
1169
2144
  }
1170
2145
  /**
1171
- * Drain the in-session write-while-alone queue: re-issue each pending authority
1172
- * membership write now that the cohort can broadcast it. Sequential (no fan-out)
1173
- * to avoid a thundering re-touch; an entry is cleared only on success, so a
1174
- * failure (or a still-alone re-commit) leaves it queued for the next growth.
1175
- *
1176
- * - `authorize`: re-issue as an idempotent monotonic authority UPDATE
1177
- * ({@link reissuePeerAuthorize}). Skipped (and cleared) if the row vanished
1178
- * (raced a delete) or now carries a self-`Sig` (the peer self-published and owns
1179
- * its republish).
1180
- * - `remove` (security-relevant): a removePeer-while-alone physically removed the
1181
- * row locally, so a re-issued DELETE matches nothing and does NOT propagate —
1182
- * full delete durability requires a schema tombstone (deferred:
1183
- * `control-delete-while-alone-tombstone`). We still attempt the re-issue
1184
- * (harmless, and it propagates in the row-still-present case); the commit-alone
1185
- * was already logged loudly in {@link noteControlWrite}.
2146
+ * Drain the in-session write-while-alone queue: re-issue each pending owner
2147
+ * authorize as an idempotent monotonic owner UPDATE
2148
+ * ({@link reissuePeerAuthorize}) now that the cohort can broadcast it.
2149
+ * Sequential (no fan-out) to avoid a thundering re-touch; an entry is cleared
2150
+ * only on success, so a failure (or a still-alone re-commit) leaves it queued
2151
+ * for the next growth. An entry is skipped (and cleared) if the row vanished
2152
+ * (raced a delete) or now carries a self-`Sig` (the peer self-published and owns
2153
+ * its republish). Removals are drained by {@link drainPendingRevocations}, not
2154
+ * here.
1186
2155
  */
1187
2156
  async drainPendingPeerWrites() {
1188
2157
  if (this.pendingPeerWrites.size === 0) {
1189
2158
  return;
1190
2159
  }
1191
2160
  if (!this.seedBootstrapService?.canAuthorize()) {
1192
- // A non-authority node cannot have authored these writes; drop any stray entries.
2161
+ // A non-owner node cannot have authored these writes; drop any stray entries.
1193
2162
  this.pendingPeerWrites.clear();
1194
2163
  return;
1195
2164
  }
1196
- for (const [peerId, kind] of [...this.pendingPeerWrites]) {
1197
- if (!this._running || !this.controlNode || !this.controlDatabase) {
2165
+ // One gate refresh for the whole drain, not one per entry: these re-issues
2166
+ // replay writes this node already applied locally (and already refreshed
2167
+ // for), so the snapshot only has to be correct once the drain settles.
2168
+ await this.deferMembershipGateRefresh('drain-reissue', () => this.reissuePendingPeerWrites());
2169
+ }
2170
+ /**
2171
+ * Body of {@link drainPendingPeerWrites}' loop. An entry is cleared only on
2172
+ * success (or when there is nothing left to re-issue); a failure is logged and
2173
+ * the entry stays queued for the next cohort growth.
2174
+ */
2175
+ async reissuePendingPeerWrites() {
2176
+ for (const peerId of [...this.pendingPeerWrites.keys()]) {
2177
+ if (!this._running || !this.controlNode || !this.controlDatabase || !this.seedBootstrapService) {
1198
2178
  return;
1199
2179
  }
1200
2180
  try {
1201
- if (kind === 'authorize') {
1202
- const record = await this.controlDatabase.queryPeerRecord(peerId);
1203
- if (!record || record.sig) {
1204
- // Removed since, or peer self-published — nothing for the authority to re-issue.
1205
- this.pendingPeerWrites.delete(peerId);
1206
- continue;
1207
- }
1208
- await this.reissuePeerAuthorize(peerId, record.updatedAt);
1209
- }
1210
- else {
1211
- // Best-effort delete re-issue (see method doc): propagates only if the row
1212
- // is somehow still present locally.
1213
- await this.seedBootstrapService.removePeer(peerId);
2181
+ const record = await this.controlDatabase.queryPeerRecord(peerId);
2182
+ if (!record || record.sig) {
2183
+ // Removed since, or peer self-published — nothing for the owner to re-issue.
2184
+ this.pendingPeerWrites.delete(peerId);
2185
+ continue;
1214
2186
  }
2187
+ await this.reissuePeerAuthorize(peerId, record.updatedAt);
1215
2188
  this.pendingPeerWrites.delete(peerId);
1216
2189
  }
1217
2190
  catch (error) {
1218
- log('drain: re-issue of %s (%s) failed; leaving queued for next growth: %o', peerId, kind, error);
2191
+ log('drain: re-issue of %s (authorize) failed; leaving queued for next growth: %o', peerId, error);
1219
2192
  }
1220
2193
  }
1221
2194
  }
1222
2195
  /**
1223
- * Re-issue an authority membership row as a monotonic authority UPDATE: bump
2196
+ * Re-issue an owner membership row as a monotonic owner UPDATE: bump
1224
2197
  * `UpdatedAt` strictly above the stored value (and the wall clock) and re-sign via
1225
- * the authority branch of `CadrePeer.AuthorizedUpdate`. The row already exists
2198
+ * the owner branch of `CadrePeer.AuthorizedUpdate`. The row already exists
1226
2199
  * locally (it committed there, just local-only), so this is an UPDATE, not the
1227
2200
  * original INSERT.
1228
2201
  */
@@ -1335,11 +2308,11 @@ export class CadreNode {
1335
2308
  * for any member — the `AuthorizedUpdate` self-branch verifies the new `Sig`
1336
2309
  * against the bound `CadrePeer.PublicKey`). Platform/Token may change here
1337
2310
  * (rotation / platform switch / reinstall are all normal self-updates).
1338
- * - If not, and the node holds an authority service: an authority-signed INSERT
2311
+ * - If not, and the node holds an owner service: an owner-signed INSERT
1339
2312
  * that also carries the self-signature.
1340
2313
  * - Otherwise: throws. Like `CadrePeer`, the first `DeviceToken` row requires an
1341
- * authority signature; a non-authority peer (e.g. a phone) must have its row
1342
- * seeded by an authority — typically the server it enrolled with — before it
2314
+ * owner signature; a non-owner peer (e.g. a phone) must have its row
2315
+ * seeded by an owner — typically the server it enrolled with — before it
1343
2316
  * can self-refresh. (Establishing that phone→server registration handshake is
1344
2317
  * the downstream "RN registration" ticket; this node only owns the cadre-core
1345
2318
  * write path.)
@@ -1350,7 +2323,7 @@ export class CadreNode {
1350
2323
  * @param platform - `'fcm'` (Android/Firebase) or `'apns'` (Apple).
1351
2324
  * @param token - the opaque platform device/registration token.
1352
2325
  * @throws if the node is not started, exposes no self-signing key, or has no
1353
- * existing row and no authority service to self-insert.
2326
+ * existing row and no owner service to self-insert.
1354
2327
  */
1355
2328
  async registerDeviceToken(platform, token) {
1356
2329
  if (!this._running || !this.controlNode || !this.controlDatabase) {
@@ -1374,14 +2347,21 @@ export class CadreNode {
1374
2347
  return;
1375
2348
  }
1376
2349
  if (this.seedBootstrapService) {
2350
+ // NOTE: the `CadrePeer` twin of this read-then-insert needed race recovery because an
2351
+ // owner `authorizePeer` can seat a peer's row for it. Nothing seats a `DeviceToken`
2352
+ // row on a peer's behalf, and this insert has no in-lock existence check, so a lost
2353
+ // race would THROW rather than silently drop the record. If an owner-driven
2354
+ // device-token seeding path is ever added, or two first-publishes can run
2355
+ // concurrently (this method has no single-flight guard, unlike `registerSelf`),
2356
+ // revisit — see `publishSelfRecord`'s fall-through.
1377
2357
  await this.seedBootstrapService.insertSelfDeviceToken(record);
1378
2358
  if (this.committedAlone())
1379
2359
  this.pendingSelfDeviceWrite = true;
1380
- log('registerDeviceToken: inserted own DeviceToken (authority-signed, platform=%s, updatedAt=%d)', platform, updatedAt);
2360
+ log('registerDeviceToken: inserted own DeviceToken (owner-signed, platform=%s, updatedAt=%d)', platform, updatedAt);
1381
2361
  return;
1382
2362
  }
1383
2363
  throw new Error(`Cannot register device token for ${peerId}: no existing row to self-update and no ` +
1384
- 'authority service to self-insert. An authority must seed this peer\'s DeviceToken ' +
2364
+ 'owner service to self-insert. An owner must seed this peer\'s DeviceToken ' +
1385
2365
  'row first (mirrors CadrePeer enrollment).');
1386
2366
  }
1387
2367
  /**
@@ -1394,8 +2374,10 @@ export class CadreNode {
1394
2374
  * 2. `publicKey <-> peerId` binding — the stored key's libp2p identity is the
1395
2375
  * requested peerId,
1396
2376
  * 3. a `DeviceToken` row exists with a known {@link PushPlatform},
1397
- * 4. self-signature verifies against the bound `CadrePeer.PublicKey`,
1398
- * 5. freshness — `updatedAt` is positive and within `opts.maxAgeMs` (default:
2377
+ * 4. the row's `StampId` is NOT retired in `CadreControl.Revocation` — the
2378
+ * read-side half of the clear (see below),
2379
+ * 5. self-signature verifies against the bound `CadrePeer.PublicKey`,
2380
+ * 6. freshness — `updatedAt` is positive and within `opts.maxAgeMs` (default:
1399
2381
  * no ceiling, since a push token is valid until it rotates).
1400
2382
  *
1401
2383
  * A peer that is not a current member, or whose token has no backing `CadrePeer`
@@ -1426,6 +2408,22 @@ export class CadreNode {
1426
2408
  log('resolveDeviceToken: unknown platform %s for %s', record.platform, peerId);
1427
2409
  return null;
1428
2410
  }
2411
+ // Retired stamp: the read-side mitigation for the write-time race, mirroring what
2412
+ // listAuthorizedMembers does for CadrePeer. Clearing a token retires its StampId
2413
+ // into Revocation, but the schema's NotRevoked CHECK only sees LOCALLY visible
2414
+ // tombstones — a node that converged on a replayed insert before the tombstone can
2415
+ // hold both rows, so the reader must drop the resurrected one.
2416
+ //
2417
+ // NOTE: the freshness ceiling below defaults to INFINITE by design (a suspended
2418
+ // phone must stay push-reachable long after it published), so unlike a peer address
2419
+ // record a stale token never ages out. Stamp retirement is the ONLY thing that
2420
+ // retires a cleared token — do not weaken this check on the assumption that
2421
+ // staleness will eventually cover it.
2422
+ const revokedStamps = await this.controlDatabase.queryRevokedStamps('DeviceToken');
2423
+ if (revokedStamps.has(record.stampId)) {
2424
+ log('resolveDeviceToken: StampId for %s is retired (cleared token, resurrected row)', peerId);
2425
+ return null;
2426
+ }
1429
2427
  // Self-signature over (peerId, platform, token, updatedAt), verified against the
1430
2428
  // CadrePeer.PublicKey bound to this peerId.
1431
2429
  if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
@@ -1444,11 +2442,11 @@ export class CadreNode {
1444
2442
  /**
1445
2443
  * Delete this node's own `DeviceToken` row (logout / token invalidation). No-op
1446
2444
  * when no row exists. Like {@link registerDeviceToken}'s first insert, the delete
1447
- * is gated on an authority signature (`DeviceToken.AuthorizedInsert` covers insert
1448
- * AND delete), so it requires this node's authority service; a non-authority peer
1449
- * must route the clear through its authority (downstream RN registration path).
2445
+ * is gated on an owner signature (`DeviceToken.AuthorizedInsert` covers insert
2446
+ * AND delete), so it requires this node's owner service; a non-owner peer
2447
+ * must route the clear through its owner (downstream RN registration path).
1450
2448
  *
1451
- * @throws if the node is not started, or a row exists but no authority service is
2449
+ * @throws if the node is not started, or a row exists but no owner service is
1452
2450
  * available to sign the delete.
1453
2451
  */
1454
2452
  async clearDeviceToken() {
@@ -1462,8 +2460,8 @@ export class CadreNode {
1462
2460
  return;
1463
2461
  }
1464
2462
  if (!this.seedBootstrapService) {
1465
- throw new Error(`Cannot clear device token for ${peerId}: delete requires an authority signature ` +
1466
- 'and no authority service is initialized.');
2463
+ throw new Error(`Cannot clear device token for ${peerId}: delete requires an owner signature ` +
2464
+ 'and no owner service is initialized.');
1467
2465
  }
1468
2466
  await this.seedBootstrapService.deleteDeviceToken(peerId);
1469
2467
  log('clearDeviceToken: deleted DeviceToken for %s', peerId);
@@ -1473,10 +2471,10 @@ export class CadreNode {
1473
2471
  * unregistered during a push-wake fan-out. Unlike {@link clearDeviceToken}
1474
2472
  * (self-only — it hardcodes the local peerId), this takes an arbitrary peerId.
1475
2473
  *
1476
- * - When this node holds an authority seed service, it deletes the row
1477
- * (`deleteDeviceToken` is authority-gated and accepts any peerId), so the peer
2474
+ * - When this node holds an owner seed service, it deletes the row
2475
+ * (`deleteDeviceToken` is owner-gated and accepts any peerId), so the peer
1478
2476
  * is not retried until it re-registers.
1479
- * - When this node is NOT an authority it cannot delete the row, so it only logs
2477
+ * - When this node is NOT an owner it cannot delete the row, so it only logs
1480
2478
  * that a re-registration is needed. The fan-out's own in-memory dead-token set
1481
2479
  * is what actually stops re-pushing to the dead token this process — see
1482
2480
  * {@link PushFanoutService}. That set is acceptably lossy across restarts (a
@@ -1488,14 +2486,14 @@ export class CadreNode {
1488
2486
  if (this.seedBootstrapService) {
1489
2487
  try {
1490
2488
  await this.seedBootstrapService.deleteDeviceToken(peerId);
1491
- log('expireDeviceToken: authority-deleted stale DeviceToken for %s', peerId);
2489
+ log('expireDeviceToken: owner-deleted stale DeviceToken for %s', peerId);
1492
2490
  }
1493
2491
  catch (error) {
1494
- log('expireDeviceToken: authority delete for %s failed: %o', peerId, error);
2492
+ log('expireDeviceToken: owner delete for %s failed: %o', peerId, error);
1495
2493
  }
1496
2494
  return;
1497
2495
  }
1498
- log('expireDeviceToken: %s token is stale but this node is not an authority; re-registration required', peerId);
2496
+ log('expireDeviceToken: %s token is stale but this node is not an owner; re-registration required', peerId);
1499
2497
  }
1500
2498
  async handleStrandAdded(strand) {
1501
2499
  log('Handling strand added from control network: %s', strand.Id);
@@ -1521,17 +2519,21 @@ export class CadreNode {
1521
2519
  strandId: strand.Id,
1522
2520
  error: error instanceof Error ? error : new Error(String(error))
1523
2521
  });
2522
+ // Rethrow after the event: the only caller is the StrandWatcher's
2523
+ // onStrandAdded callback, which uses the rejection to decide whether the
2524
+ // strand was really added. Swallowing it here would have the watcher mark
2525
+ // the strand as known-and-added, so no later poll would ever retry it.
2526
+ // The watcher catches and logs, so this never escapes as an unhandled
2527
+ // rejection.
2528
+ throw error;
1524
2529
  }
1525
2530
  }
1526
2531
  async handleStrandRemoved(strandId) {
1527
2532
  log('Handling strand removed: %s', strandId);
1528
2533
  try {
1529
- // Untrack from hibernation
1530
- this.hibernationManager.untrackStrand(strandId);
1531
- // Remove sApp config
1532
- this.sAppConfigs.delete(strandId);
1533
- await this.strandManager.stopStrand(strandId);
1534
- this.emit('strand:stopped', { strandId });
2534
+ // Same local teardown the explicit stop performs — deliberately NOT stopStrand
2535
+ // itself, whose `_running` guard would throw on a poll that lands during shutdown.
2536
+ await this.detachStrand(strandId);
1535
2537
  }
1536
2538
  catch (error) {
1537
2539
  log('Error stopping strand %s: %o', strandId, error);
@@ -1589,8 +2591,18 @@ export class CadreNode {
1589
2591
  await this.strandManager.stopAll();
1590
2592
  // Clear sApp configs
1591
2593
  this.sAppConfigs.clear();
1592
- // Close control database (this also shuts down the collection factory)
2594
+ // Drop delegate-admission state: the grants are scoped to the session that
2595
+ // recorded them, so a stop()/start() cycle on this object must not keep
2596
+ // admitting delegates announced under the previous one, nor carry stale
2597
+ // announce timestamps that would suppress the next session's refresh.
2598
+ this.delegateAdmission.clear();
2599
+ this.delegateAnnounceAt.clear();
2600
+ // Close control database (this also shuts down the collection factory).
2601
+ // Detach the membership hub first: nothing this teardown does should drive a
2602
+ // gate refresh, and a notification arriving after this point is a no-op.
1593
2603
  if (this.controlDatabase) {
2604
+ this.controlDatabase.setMembershipChangeListener(null);
2605
+ this.controlDatabase.setGuardedDeleteListener(null);
1594
2606
  await this.controlDatabase.close();
1595
2607
  this.controlDatabase = null;
1596
2608
  }
@@ -1674,7 +2686,13 @@ export class CadreNode {
1674
2686
  * (returns the live instance unchanged) as a backstop against double-resume.
1675
2687
  */
1676
2688
  async resumeStrandRuntime(strandId) {
1677
- const seed = await this.resolveCohortSeed(strandId);
2689
+ // Re-derive the transport peerId (deterministic and cheap — a quiesced
2690
+ // instance has no `libp2pNode` to read it from) so the seed pass announces
2691
+ // the delegate before `resumeStrand` runs `libp2p.start()`.
2692
+ const delegatePeerId = this.identityKey
2693
+ ? peerIdFromPrivateKey(await strandTransportKey(this.identityKey, strandId)).toString()
2694
+ : undefined;
2695
+ const seed = await this.resolveCohortSeed(strandId, delegatePeerId);
1678
2696
  const mode = selectStrandMode(undefined, seed.hasOtherPeers);
1679
2697
  await this.strandManager.resumeStrand(strandId, {
1680
2698
  bootstrapNodes: seed.bootstrapNodes,
@@ -1806,6 +2824,14 @@ export class CadreNode {
1806
2824
  /**
1807
2825
  * Add a strand with its sApp configuration.
1808
2826
  * The hosting application must provide the sApp schema when creating a strand.
2827
+ *
2828
+ * A rejected call leaves nothing running but DOES leave the sApp config
2829
+ * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
2830
+ * automatic relaunch need it. So once the strand's row is visible on the control
2831
+ * network (via {@link publishStrand} or another member), a failed launch keeps
2832
+ * being re-attempted in the background — each failure re-emitting `strand:error`
2833
+ * — until it succeeds. {@link detachStrand} (reached via {@link stopStrand}) is
2834
+ * what abandons a strand for good.
1809
2835
  */
1810
2836
  async addStrand(config) {
1811
2837
  if (!this._running) {
@@ -1823,20 +2849,20 @@ export class CadreNode {
1823
2849
  }
1824
2850
  /**
1825
2851
  * Publish a strand row to the shared control database under this node's own
1826
- * authority identity, so other cadre members discover it via control-network
2852
+ * owner identity, so other cadre members discover it via control-network
1827
2853
  * sync (their {@link StrandWatcher} fires `strand:discovered`).
1828
2854
  *
1829
- * This is the authority-signed `Strand` INSERT that {@link addStrand}
2855
+ * This is the owner-signed `Strand` INSERT that {@link addStrand}
1830
2856
  * deliberately omits: `addStrand` only starts the LOCAL strand instance,
1831
2857
  * whereas publishing makes the strand visible cadre-wide. A typical creator
1832
2858
  * does both (start locally + publish); a discovering peer only does
1833
2859
  * `addStrand` (the row already exists).
1834
2860
  *
1835
2861
  * The insert is signed with the ed25519 key behind this node's PeerId — which
1836
- * {@link authorityKeyFromLibp2p} also exposes as the node's authority keypair,
1837
- * so peer identity and authority key are one and the same. That key must be
1838
- * enrolled in `AuthorityKey` (e.g. via {@link ControlDatabase.ensureAuthorityKey}
1839
- * at genesis) or the schema's `Strand.Authorized` constraint rejects the write.
2862
+ * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
2863
+ * so peer identity and owner key are one and the same. That key must be
2864
+ * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
2865
+ * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
1840
2866
  * Failing loudly here is intentional: a silently-unpublished strand would run
1841
2867
  * as a local-only island that no peer could ever discover or join.
1842
2868
  *
@@ -1844,30 +2870,99 @@ export class CadreNode {
1844
2870
  * {@link addStrand}).
1845
2871
  * @param type - `'o'` for open (default) or `'c'` for closed.
1846
2872
  * @param memberPrivateKey - Optional membership key for a closed strand.
1847
- * @throws if the node is not started, exposes no authority signing key, or the
1848
- * control DB rejects the (unauthorized) insert.
2873
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, or
2874
+ * the control DB rejects the (unauthorized) insert.
1849
2875
  */
1850
2876
  async publishStrand(strandId, type = 'o', memberPrivateKey) {
1851
- if (!this._running || !this.controlDatabase) {
1852
- throw new Error('CadreNode must be started before publishing a strand');
1853
- }
1854
- const signingKey = this.getSelfSigningKey();
1855
- if (!signingKey) {
1856
- throw new Error(`Cannot publish strand ${strandId}: no authority signing key available ` +
1857
- '(node identity is unavailable or does not match the node PeerId). Run authority ' +
1858
- 'genesis (ensureAuthorityKey + initializeSeedBootstrap) before publishing.');
2877
+ const signingKey = this.requireOwnerSigningKey(`publish strand ${strandId}`);
2878
+ // Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
2879
+ // trims too, and an untrimmed row would be unreachable by the same string.
2880
+ const trimmed = requireNonBlank(strandId, 'strand id');
2881
+ await this.controlDatabase.insertStrand(trimmed, type, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64), memberPrivateKey);
2882
+ log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signingKey.publicKeyB64);
2883
+ }
2884
+ /**
2885
+ * Remove this party's `Strand` row from the shared control database — the owner-signed
2886
+ * inverse of {@link publishStrand}.
2887
+ *
2888
+ * Party-wide, unlike {@link stopStrand}, which only stops the strand on THIS node: every
2889
+ * cadre node watching the table sees the row vanish on its next poll (default 5 s) and
2890
+ * stops its own instance. This node converges immediately — the method forces a watcher
2891
+ * poll and stops any still-running local instance before resolving. Removing the row
2892
+ * removes OUR party's participation only: other parties in the strand keep their own
2893
+ * rows and the strand network carries on, so no cross-party sign-off is involved — this
2894
+ * is an owner-signed control-plane write like every other, not "destroy the network".
2895
+ *
2896
+ * Irreversible for a closed strand (`Type='c'`): the row carries `MemberPrivateKey`,
2897
+ * this party's membership secret for that network, and it is stored nowhere else.
2898
+ * With it gone the party can never again admit a member to that closed strand, and a
2899
+ * re-published row would carry a DIFFERENT key that does not match the membership
2900
+ * already written into the strand's RBAC layer. The strand id itself is NOT
2901
+ * blacklisted: a fresh owner-signed {@link publishStrand} re-seats it under a new
2902
+ * stamp — only the unsigned consent re-seat is permanently foreclosed (the removal's
2903
+ * `Revocation` tombstone names the id, which the consent branch of
2904
+ * `Strand.AuthorizedInsert` refuses ever after). Outstanding `FormationInvite` rows
2905
+ * bound to the removed strand are unredeemable while the row is absent (the formation
2906
+ * recorder resolves the strand as missing and rejects cleanly).
2907
+ *
2908
+ * Convergence caveats, same class as {@link enrollValidationKey}'s: a sibling that has
2909
+ * not yet synced keeps running the strand until its own watcher observes the missing
2910
+ * row; a sibling whose `strandFilter` never admitted this strand never observes the
2911
+ * removal AT ALL and keeps running its instance indefinitely — opting out of watching a
2912
+ * strand is also opting out of its party-wide removal, so such a node's only stop is its
2913
+ * own local {@link stopStrand}/`unpublishStrand` call; and a removal committed while
2914
+ * ALONE (0 control connections) deletes the row local-only. The accompanying
2915
+ * `Revocation` tombstone IS queued and re-issued on the next cohort-growth edge
2916
+ * ({@link noteGuardedDelete}/{@link drainPendingRevocations}), so the stamp retirement
2917
+ * — and with it the consent re-seat foreclosure — does propagate; but the physical row
2918
+ * deletion cannot be replayed, and `queryStrands` reads raw (no retired-stamp filter),
2919
+ * so siblings that already hold the row keep running the strand until the collection
2920
+ * itself converges (logged loudly; see the delete-while-alone durability note in
2921
+ * docs/architecture.md).
2922
+ *
2923
+ * A no-op (no throw, no tombstone) when the row is already absent — but a
2924
+ * locally-running instance of that id is still stopped.
2925
+ *
2926
+ * NOTE: control-plane only — the strand's local durable storage is retained. Stopping the
2927
+ * instance closes the `StrandDatabase` and its libp2p node but purges no blocks, so a
2928
+ * closed strand's content stays readable on disk to anyone with the data directory. If a
2929
+ * caller ever needs removal to mean "and erase the local copy", that is a separate purge
2930
+ * step, not a widening of this method.
2931
+ *
2932
+ * @param strandId - The `Strand` row id to remove (as passed to {@link publishStrand}).
2933
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, or
2934
+ * the signer is not an enrolled owner (the schema's `Strand.AuthorizedDelete` rejects
2935
+ * the write and the row survives). A rejection does NOT imply the row survived: the
2936
+ * local stop runs after the control-plane delete has already committed, so a failure
2937
+ * there throws over a completed removal.
2938
+ */
2939
+ async unpublishStrand(strandId) {
2940
+ const signingKey = this.requireOwnerSigningKey(`unpublish strand ${strandId}`);
2941
+ const trimmed = requireNonBlank(strandId, 'strand id');
2942
+ const removed = await this.controlDatabase.deleteStrand(trimmed, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
2943
+ // Gate on `removed`: the absent-row no-op wrote nothing, so warning about an
2944
+ // unreplicated deletion there would send an operator chasing a phantom.
2945
+ if (removed && this.committedAlone()) {
2946
+ log('unpublishStrand(%s) committed while ALONE (0 control connections): the deletion is ' +
2947
+ 'local-only. Its Revocation tombstone is queued for re-issue on cohort growth, but the ' +
2948
+ 'row deletion itself cannot be replayed, so other nodes may keep running the strand ' +
2949
+ 'until the collection converges.', trimmed);
2950
+ }
2951
+ // Converge locally now rather than waiting up to a poll interval. The watcher fires
2952
+ // onStrandRemoved for a strand it tracked; the explicit stop below covers a node whose
2953
+ // strandFilter never admitted this strand (the watcher never knew it, so it will never
2954
+ // fire). StrandInstanceManager.stopStrand no-ops on an unknown id, so the two paths
2955
+ // cannot double-stop into an error.
2956
+ await this.strandWatcher?.forcePoll();
2957
+ if (this.strandManager.getInstance(trimmed)) {
2958
+ await this.stopStrand(trimmed);
1859
2959
  }
1860
- // insertStrand hands this callback the canonical row-bound message BYTES (see
1861
- // buildAuthorizationMessage); ed25519-sign them directly (no pre-hash) with
1862
- // the authority private key, returning a base64url signature.
1863
- const signMessage = (message) => sign(message, signingKey.privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
1864
- await this.controlDatabase.insertStrand(strandId, type, signingKey.publicKeyB64, signMessage, memberPrivateKey);
1865
- log('Published strand %s (type %s) to control DB under authority %s', strandId, type, signingKey.publicKeyB64);
2960
+ log('Unpublished strand %s from control DB under owner %s', trimmed, signingKey.publicKeyB64);
1866
2961
  }
1867
2962
  /**
1868
- * Publish an authority-signed `FormationInvite` (open-invitation token) to the
2963
+ * Publish an owner-signed `FormationInvite` (open-invitation token) to the
1869
2964
  * shared control database, so a later {@link formStrand} redemption can be
1870
- * validated against it (the consent branch of `Strand.Authorized`).
2965
+ * validated against it (the consent branch of `Strand.AuthorizedInsert`).
1871
2966
  *
1872
2967
  * Counterpart to {@link createOpenInvitation}, which only mints the
1873
2968
  * out-of-band {@link OpenInvitation} envelope: persisting the matching
@@ -1876,8 +2971,8 @@ export class CadreNode {
1876
2971
  * from this row. A host minting a closed-strand invite does both (mint +
1877
2972
  * publish), exactly as the integration harness's `createInvitation` does.
1878
2973
  *
1879
- * Signs with the same self-authority key as {@link publishStrand} (the ed25519
1880
- * key behind this node's PeerId, which must be an enrolled `AuthorityKey`).
2974
+ * Signs with the same self-owner key as {@link publishStrand} (the ed25519
2975
+ * key behind this node's PeerId, which must be an enrolled `OwnerKey`).
1881
2976
  * Throws loudly if the node isn't started or exposes no signing key.
1882
2977
  *
1883
2978
  * @param token - Invitation token (the `FormationInvite` primary key); use the
@@ -1887,27 +2982,147 @@ export class CadreNode {
1887
2982
  * `strandId` (bind a closed/pre-existing host strand for provision-then-record).
1888
2983
  */
1889
2984
  async publishFormationInvite(token, sAppId, options = {}) {
2985
+ const signingKey = this.requireOwnerSigningKey(`publish formation invite ${token}`);
2986
+ await this.controlDatabase.insertFormationInvite(token, sAppId, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64), options);
2987
+ // A host may publish an invite whose token was minted elsewhere; registering
2988
+ // it locally opens the connection gate's formation exemption immediately
2989
+ // rather than waiting for the durable row to become readable.
2990
+ this.strandSolicitationService?.registerMintedInvitation(token, options.expiresAtMs ?? Number.POSITIVE_INFINITY);
2991
+ log('Published formation invite %s (sApp %s) under owner %s', token, sAppId, signingKey.publicKeyB64);
2992
+ }
2993
+ /**
2994
+ * Enroll an approver public key allowed to sign off on `ValidationUrl` redemptions.
2995
+ *
2996
+ * A `FormationInvite` carrying a `ValidationUrl` is only redeemable when the approval
2997
+ * that comes back from that URL is signed by a key present in `CadreControl.ValidationKey`
2998
+ * — this is how a party says which outside approver it trusts. Without an enrollment,
2999
+ * every such invitation is unredeemable.
3000
+ *
3001
+ * Signs with the same self-owner key as {@link publishStrand} (the ed25519 key behind
3002
+ * this node's PeerId, which must be an enrolled `OwnerKey`). Throws loudly if the node
3003
+ * isn't started, exposes no signing key, or the key is blank — a blank key would reach
3004
+ * the database and fail the signature CHECK as an opaque constraint error.
3005
+ *
3006
+ * Enrolling this node's OWN owner key is permitted and harmless: the domain/action tags
3007
+ * baked into each authorization digest mean an owner-key signature can never satisfy the
3008
+ * approval rule and an approval can never satisfy an owner rule. No guard is needed.
3009
+ *
3010
+ * NOTE: enrollment is replicated control state, so a key enrolled here is visible to a
3011
+ * sibling cadre node only once control replication converges. A redemption that arrives
3012
+ * at a node which has not caught up is refused as not-enrolled — the same convergence
3013
+ * gap the schema records on `Strand.StampId`. Enroll before circulating the invitations
3014
+ * that depend on the key.
3015
+ *
3016
+ * @param key - Approver public key to enroll (base64url ed25519 public key).
3017
+ */
3018
+ async enrollValidationKey(key) {
3019
+ const signingKey = this.requireOwnerSigningKey('enroll a validation key');
3020
+ const trimmed = requireEd25519PublicKeyB64(key, 'validation key');
3021
+ await this.controlDatabase.insertValidationKey(trimmed, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
3022
+ log('Enrolled validation key %s under owner %s', trimmed, signingKey.publicKeyB64);
3023
+ }
3024
+ /**
3025
+ * Remove an approver public key.
3026
+ *
3027
+ * Narrows who may approve FUTURE redemptions ONLY. The schema's approval CHECKs run at
3028
+ * write time, so a join that was already approved by this key stays valid — removal is
3029
+ * not retroactive and does not re-examine committed rows.
3030
+ *
3031
+ * Rotation is therefore add-then-remove, in that order: removing the only enrolled key
3032
+ * while `ValidationUrl` invitations are outstanding makes every one of them unredeemable
3033
+ * until a new key is enrolled.
3034
+ *
3035
+ * Removing a key that is not enrolled is a silent no-op (no throw, no `Revocation`
3036
+ * tombstone) — see {@link ControlDatabase.deleteValidationKey}.
3037
+ *
3038
+ * @param key - Approver public key to remove (base64url ed25519 public key).
3039
+ */
3040
+ async removeValidationKey(key) {
3041
+ const signingKey = this.requireOwnerSigningKey('remove a validation key');
3042
+ const trimmed = requireNonBlank(key, 'validation key');
3043
+ await this.controlDatabase.deleteValidationKey(trimmed, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
3044
+ log('Removed validation key %s under owner %s', trimmed, signingKey.publicKeyB64);
3045
+ }
3046
+ /**
3047
+ * The approver keys currently enrolled, sorted. Read-only, so unlike
3048
+ * {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner signing
3049
+ * key — only a started node.
3050
+ */
3051
+ async listValidationKeys() {
1890
3052
  if (!this._running || !this.controlDatabase) {
1891
- throw new Error('CadreNode must be started before publishing a formation invite');
3053
+ throw new Error('CadreNode must be started before listing validation keys');
3054
+ }
3055
+ return await this.controlDatabase.queryValidationKeys();
3056
+ }
3057
+ /**
3058
+ * Resolve the owner keypair every owner-signed control write needs — {@link publishStrand},
3059
+ * {@link unpublishStrand}, {@link publishFormationInvite}, {@link enrollValidationKey},
3060
+ * {@link removeValidationKey}
3061
+ * — failing loudly in one two-part shape (not started / no signing key, naming owner
3062
+ * genesis as the fix). Narrows `controlDatabase` for the caller: a non-null return means
3063
+ * `this.controlDatabase` is non-null too.
3064
+ *
3065
+ * @param action - Infinitive phrase naming the attempted write, e.g. `'enroll a
3066
+ * validation key'`; interpolated into both messages.
3067
+ */
3068
+ requireOwnerSigningKey(action) {
3069
+ if (!this._running || !this.controlDatabase) {
3070
+ throw new Error(`CadreNode must be started before attempting to ${action}`);
1892
3071
  }
1893
3072
  const signingKey = this.getSelfSigningKey();
1894
3073
  if (!signingKey) {
1895
- throw new Error(`Cannot publish formation invite ${token}: no authority signing key available ` +
1896
- '(node identity is unavailable or does not match the node PeerId). Run authority ' +
1897
- 'genesis (ensureAuthorityKey + initializeSeedBootstrap) before publishing.');
3074
+ throw new Error(`Cannot ${action}: no owner signing key available ` +
3075
+ '(node identity is unavailable or does not match the node PeerId). Run owner ' +
3076
+ 'genesis (ensureOwnerKey + initializeSeedBootstrap) before writing owner-signed ' +
3077
+ 'control state.');
1898
3078
  }
1899
- const signMessage = (message) => sign(message, signingKey.privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
1900
- await this.controlDatabase.insertFormationInvite(token, sAppId, signingKey.publicKeyB64, signMessage, options);
1901
- log('Published formation invite %s (sApp %s) under authority %s', token, sAppId, signingKey.publicKeyB64);
3079
+ return signingKey;
1902
3080
  }
1903
3081
  /**
1904
3082
  * Shared strand launch path for both the explicit (`addStrand`) and the
1905
3083
  * control-discovered (`handleStrandAdded`) entry points. Resolves the cohort
1906
3084
  * seed, selects the mode, starts the strand, and registers it with the
1907
3085
  * hibernation manager before emitting `strand:started`.
3086
+ *
3087
+ * Idempotent no-op when the strand manager already tracks `strand.Id` — the
3088
+ * ordinary founding sequence is `addStrand` (starts it locally) followed by
3089
+ * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
3090
+ * rediscovers a row it already started and calls this again via
3091
+ * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
3092
+ * (already-connected) cohort seed and re-emit `strand:started` for an
3093
+ * instance that never stopped. The guard runs before the cohort-seed RPC
3094
+ * fan-out, so a rediscovery costs nothing beyond the map lookup.
1908
3095
  */
1909
3096
  async launchStrand(strand, sAppConfig, explicitMode, founder) {
1910
- const seed = await this.resolveCohortSeed(strand.Id);
3097
+ const existing = this.strandManager.getInstance(strand.Id);
3098
+ if (existing) {
3099
+ log('launchStrand: strand %s already tracked locally — skipping re-launch', strand.Id);
3100
+ return existing;
3101
+ }
3102
+ // Each strand node gets its own transport identity, derived from the cadre
3103
+ // identity key + strandId (see strand-transport-key.ts). Sharing the
3104
+ // control node's key here gave every node one peerId, which collides at a
3105
+ // shared circuit relay (github.com/gotchoices/sereus/issues/1). The
3106
+ // retained launch config carries this derived key, so hibernate → wake
3107
+ // (resumeStrand) reuses the same peerId.
3108
+ //
3109
+ // NOTE: derivation requires an Ed25519 identity key, so a node configured
3110
+ // with some other key type now fails strand launch outright (surfaced as
3111
+ // `strand:error` / a rejected addStrand) where it previously started the
3112
+ // strand on that key. Ed25519 is already required for every control-DB
3113
+ // signing path, so nothing reachable today hits this; if a non-Ed25519
3114
+ // identity is ever supported, fall back to `undefined` here — libp2p then
3115
+ // generates a random per-strand key, which still avoids the collision but
3116
+ // gives up peerId stability across restarts.
3117
+ const transportKey = this.identityKey
3118
+ ? await strandTransportKey(this.identityKey, strand.Id)
3119
+ : undefined;
3120
+ // Derived BEFORE seed resolution so the seed pass doubles as the delegate
3121
+ // announcement and every grant is recorded before `startStrand` runs
3122
+ // `libp2p.start()` (the responder records the grant before replying; the
3123
+ // client awaits the replies).
3124
+ const delegatePeerId = transportKey ? peerIdFromPrivateKey(transportKey).toString() : undefined;
3125
+ const seed = await this.resolveCohortSeed(strand.Id, delegatePeerId);
1911
3126
  const mode = selectStrandMode(explicitMode, seed.hasOtherPeers);
1912
3127
  const instance = await this.strandManager.startStrand({
1913
3128
  strandRow: strand,
@@ -1916,10 +3131,12 @@ export class CadreNode {
1916
3131
  network: this.config.network,
1917
3132
  profile: this.config.profile,
1918
3133
  defaultLatencyHint: this.config.hibernation?.defaultLatencyHint ?? 'interactive',
1919
- privateKey: this.identityKey,
3134
+ privateKey: transportKey,
1920
3135
  bootstrapNodes: seed.bootstrapNodes,
1921
3136
  mode,
1922
3137
  requireSignedSchemas: this.config.requireSignedSchemas,
3138
+ clusterSize: this.config.strandClusterSize,
3139
+ backfill: this.config.strandBackfill,
1923
3140
  founder
1924
3141
  });
1925
3142
  this.hibernationManager.trackStrand(instance);
@@ -1939,8 +3156,19 @@ export class CadreNode {
1939
3156
  * empty (`[]`) — mode still follows membership, and the empty seed self-heals
1940
3157
  * on the next resume / check-in pass. Returns an empty seed when the control DB
1941
3158
  * or node is absent (not yet started / torn down).
3159
+ *
3160
+ * When `delegatePeerId` is given (a strand launch/resume is imminent), the
3161
+ * pass doubles as the delegate ANNOUNCEMENT: our circuit relays are merged
3162
+ * into the RPC targets and every request carries the delegate peerId, so each
3163
+ * receiver records an admission grant BEFORE `libp2p.start()` (re-)dials the
3164
+ * relay reservation — the gate denial there is fatal, not degraded. A
3165
+ * party-member relay answers the RPC (its control node admits us as a
3166
+ * member); a dedicated ops/ relay does not speak the protocol and the
3167
+ * per-peer failure folds to `[]` — harmless, it has no membership gate and
3168
+ * needs no grant. The relay's direct addr rides along as the dial fallback
3169
+ * for a relay we are not yet connected to.
1942
3170
  */
1943
- async resolveCohortSeed(strandId) {
3171
+ async resolveCohortSeed(strandId, delegatePeerId) {
1944
3172
  if (!this.controlDatabase || !this.controlNode) {
1945
3173
  return { bootstrapNodes: [], hasOtherPeers: false };
1946
3174
  }
@@ -1949,12 +3177,106 @@ export class CadreNode {
1949
3177
  // RPC only siblings we already have a control connection to — they can answer
1950
3178
  // now, and `dialProtocol` by peerId reuses the open connection.
1951
3179
  const connected = new Set(this.controlNode.getConnections().map((c) => c.remotePeer.toString()));
1952
- const targets = otherPeerIds.filter((id) => connected.has(id));
3180
+ const targets = otherPeerIds
3181
+ .filter((id) => connected.has(id))
3182
+ .map((peerId) => ({ peerId }));
3183
+ const relays = delegatePeerId === undefined ? [] : this.circuitRelayTargets();
3184
+ for (const relay of relays) {
3185
+ if (!targets.some((t) => t.peerId === relay.relayPeerId)) {
3186
+ targets.push(relayStrandAddrPeer(relay));
3187
+ }
3188
+ }
1953
3189
  const bootstrapNodes = targets.length
1954
- ? await collectStrandAddrs(this.controlNode, targets.map((peerId) => ({ peerId })), strandId)
3190
+ ? await collectStrandAddrs(this.controlNode, targets, strandId, { delegatePeerId })
1955
3191
  : [];
3192
+ // Throttle state for the RELAY targets only — refreshDelegateGrants never
3193
+ // looks up a sibling key, so recording one would only be dead weight.
3194
+ this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId);
1956
3195
  return { bootstrapNodes, hasOtherPeers };
1957
3196
  }
3197
+ /**
3198
+ * The circuit relays this node's strand nodes would reserve through: the
3199
+ * union of the RESOLVED listen addrs' circuits — a hand-written
3200
+ * `network.listenAddrs` circuit entry or a `network.relayAddrs` entry, which
3201
+ * the strand node inherits verbatim either way — and the control node's own
3202
+ * live `/p2p-circuit` multiaddrs (a reservation this node discovered rather
3203
+ * than configured).
3204
+ *
3205
+ * NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
3206
+ * source) gets no announcement, and a membership-gated one will deny it; fine
3207
+ * now, every realistic topology feeds one of the two sources.
3208
+ */
3209
+ circuitRelayTargets() {
3210
+ return extractCircuitRelayTargets([
3211
+ ...(resolveListenAddrs(this.config.network) ?? []),
3212
+ ...(this.controlNode?.getMultiaddrs().map(String) ?? [])
3213
+ ]);
3214
+ }
3215
+ /**
3216
+ * Record the announce timestamps {@link refreshDelegateGrants} throttles on,
3217
+ * for every relay a delegate-carrying announce pass dialed.
3218
+ *
3219
+ * Recorded OPTIMISTICALLY at announce time: `collectStrandAddrs` folds
3220
+ * per-peer failure to `[]` and reports no per-peer success, and threading
3221
+ * success out would change its API for little gain. A failed INITIAL announce
3222
+ * is fatal-at-start anyway (the relay denies the reservation,
3223
+ * `libp2p.start()` throws, and wake/check-in re-resume retries with a fresh
3224
+ * announce); a failed REFRESH retries within `DELEGATE_GRANT_TTL_MS / 2`
3225
+ * (15 min), still inside the 30 min TTL.
3226
+ */
3227
+ recordDelegateAnnounces(relayPeerIds, strandId, now = Date.now()) {
3228
+ for (const relayPeerId of relayPeerIds) {
3229
+ this.delegateAnnounceAt.set(peerStrandKey(relayPeerId, strandId), now);
3230
+ }
3231
+ }
3232
+ /**
3233
+ * Re-announce every running strand's delegate peerId to this node's circuit
3234
+ * relays, throttled to once per `DELEGATE_GRANT_TTL_MS / 2` per
3235
+ * (relay, strand). A grant must outlive the reservation: a dropped relay
3236
+ * connection makes the strand's circuit-relay transport re-dial and face the
3237
+ * connection gate again, so the relay must still hold a live grant then.
3238
+ * RELAY targets only — siblings get their announce on every launch/resume
3239
+ * seed pass, and a grant only matters where a reservation can be re-dialed.
3240
+ *
3241
+ * Strands announce CONCURRENTLY: this runs ahead of the reconcile pass's
3242
+ * sibling enumeration, and one unreachable relay costs a dial timeout per
3243
+ * target, which must not stack up per strand.
3244
+ */
3245
+ async refreshDelegateGrants(now = Date.now()) {
3246
+ if (!this.controlNode) {
3247
+ return;
3248
+ }
3249
+ // A running strand's delegate peerId is simply its live node's peerId — no
3250
+ // re-derivation here.
3251
+ const running = new Map();
3252
+ for (const [strandId, instance] of this.strandManager.getInstances()) {
3253
+ if (instance.libp2pNode) {
3254
+ running.set(strandId, instance.libp2pNode.peerId.toString());
3255
+ }
3256
+ }
3257
+ pruneStoppedStrandAnnounces(this.delegateAnnounceAt, new Set(running.keys()));
3258
+ if (running.size === 0) {
3259
+ return;
3260
+ }
3261
+ const relays = this.circuitRelayTargets();
3262
+ if (relays.length === 0) {
3263
+ return;
3264
+ }
3265
+ await Promise.all([...running].map(([strandId, delegatePeerId]) => this.announceDelegateToDueRelays(strandId, delegatePeerId, relays, now)));
3266
+ }
3267
+ /** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
3268
+ async announceDelegateToDueRelays(strandId, delegatePeerId, relays, now) {
3269
+ const controlNode = this.controlNode;
3270
+ if (!controlNode) {
3271
+ return;
3272
+ }
3273
+ const due = dueRelayAnnounces(this.delegateAnnounceAt, relays, strandId, now);
3274
+ if (due.length === 0) {
3275
+ return;
3276
+ }
3277
+ await collectStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, { delegatePeerId });
3278
+ this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, now);
3279
+ }
1958
3280
  /**
1959
3281
  * The local strand instance's dialable strand-network multiaddrs for the
1960
3282
  * strand-addr RPC, ordered signaling-first (reusing the control node's
@@ -1972,16 +3294,37 @@ export class CadreNode {
1972
3294
  return orderSignalingFirst(node.getMultiaddrs().map((ma) => ma.toString()));
1973
3295
  }
1974
3296
  /**
1975
- * Remove a strand
3297
+ * Stop a strand on THIS node only: untrack it from hibernation, drop its sApp config,
3298
+ * stop the local instance, and emit `strand:stopped`. The shared `Strand` row is left
3299
+ * intact, so on the next node restart (or watcher poll) the strand is rediscovered and
3300
+ * surfaces as `strand:discovered` again. Party-wide removal is {@link unpublishStrand}.
1976
3301
  */
1977
- async removeStrand(strandId) {
3302
+ async stopStrand(strandId) {
1978
3303
  if (!this._running) {
1979
3304
  throw new Error('CadreNode not running');
1980
3305
  }
1981
- // Untrack from hibernation
3306
+ await this.detachStrand(strandId);
3307
+ }
3308
+ /**
3309
+ * Local teardown for one strand, shared by the caller-driven {@link stopStrand} and the
3310
+ * watcher-driven `handleStrandRemoved`: untrack hibernation, drop the sApp config, stop
3311
+ * the instance, emit `strand:stopped`. Touches no control-plane row — which side of the
3312
+ * removal the node is on is the caller's concern, not this method's.
3313
+ *
3314
+ * The stop + emit are skipped when the strand manager holds no instance for `strandId` —
3315
+ * e.g. a party owner that published a strand's row but never ran it locally, or an
3316
+ * explicit {@link stopStrand} for an id this node never started. `hibernationManager`
3317
+ * untrack and the `sAppConfigs` delete stay unconditional (both are no-ops when there is
3318
+ * nothing to remove), so a launch that failed before an instance was ever tracked still
3319
+ * gets its stray sApp config cleared.
3320
+ */
3321
+ async detachStrand(strandId) {
1982
3322
  this.hibernationManager.untrackStrand(strandId);
1983
- // Remove sApp config
1984
3323
  this.sAppConfigs.delete(strandId);
3324
+ if (!this.strandManager.hasStrand(strandId)) {
3325
+ log('detachStrand: strand %s not tracked locally — no-op (no stop, no strand:stopped)', strandId);
3326
+ return;
3327
+ }
1985
3328
  await this.strandManager.stopStrand(strandId);
1986
3329
  this.emit('strand:stopped', { strandId });
1987
3330
  }
@@ -2165,12 +3508,86 @@ export class CadreNode {
2165
3508
  getControlNode() {
2166
3509
  return this.controlNode;
2167
3510
  }
3511
+ /**
3512
+ * Start keeping a relay reservation: dial the given relay(s) from the control
3513
+ * node, ask the first one that answers for a reservation slot, wait until the
3514
+ * resulting `/p2p-circuit` address makes this node dialable — and keep a
3515
+ * supervisor running that re-drives whenever the reservation is later lost.
3516
+ *
3517
+ * For nodes that listen on the bare `/p2p-circuit` SEARCH addr (a browser tab)
3518
+ * rather than a configured `network.relayAddrs` circuit listener — see
3519
+ * `relay-reservation.ts` for why the two are alternatives and why configuring
3520
+ * both is a regression. Opt-in: nothing calls this for the CLI/host nodes.
3521
+ *
3522
+ * Resolves once the FIRST attempt has settled, exactly as it did when it drove
3523
+ * once; the retries continue in the background until {@link stop} or the next
3524
+ * `reserveRelays` call. Fail-soft — never throws. An unreachable relay, a
3525
+ * missing control node, or a timeout all resolve to a non-`reserved` status, so
3526
+ * a caller can await this during startup without a dead relay aborting the node.
3527
+ *
3528
+ * Passing an empty list stops the supervisor and resets the posture to `none`
3529
+ * without dialing or waiting.
3530
+ */
3531
+ async reserveRelays(addrs, opts) {
3532
+ // Stop first, unconditionally: a second call with a different list must not
3533
+ // leave two loops fighting over one pending reservation slot.
3534
+ this.relayReserveSupervisor?.stop();
3535
+ this.relayReserveSupervisor = null;
3536
+ this.relayReserveAddrs = [...addrs];
3537
+ this.relayReserveError = null;
3538
+ if (addrs.length === 0) {
3539
+ return this.getRelayReservationState();
3540
+ }
3541
+ if (!this.controlNode) {
3542
+ // No node to supervise, so this is the one failure the node records itself.
3543
+ this.relayReserveError = 'control node unavailable';
3544
+ return this.getRelayReservationState();
3545
+ }
3546
+ const supervisor = superviseRelayReservation(this.controlNode, addrs, opts);
3547
+ this.relayReserveSupervisor = supervisor;
3548
+ await supervisor.firstAttempt;
3549
+ // Read through `this`, not `supervisor`: an overlapping call may have replaced
3550
+ // it while the first attempt was in flight, and the newest list is the truth.
3551
+ return this.getRelayReservationState();
3552
+ }
3553
+ /**
3554
+ * The node's CURRENT relay-reservation posture, recomputed from the control
3555
+ * node's live multiaddrs on every call.
3556
+ *
3557
+ * Deliberately not memoised: a reservation can be lost after
3558
+ * {@link reserveRelays} succeeds (the relay restarts, the connection drops) and
3559
+ * a cached `reserved` would let a caller mint invitations carrying circuit
3560
+ * addresses that no longer route.
3561
+ *
3562
+ * A lost reservation now recovers on its own — the supervisor {@link reserveRelays}
3563
+ * starts re-drives on a backoff, reporting `retrying` meanwhile. `error` here
3564
+ * means nothing is going to try again: no supervisor, or no control node.
3565
+ */
3566
+ getRelayReservationState() {
3567
+ const supervisor = this.relayReserveSupervisor;
3568
+ return resolveRelayReservationState(this.controlNode, this.relayReserveAddrs, supervisor ? supervisor.lastError : this.relayReserveError, supervisor?.driving ?? false, supervisor?.retryAtMs ?? null);
3569
+ }
2168
3570
  /**
2169
3571
  * Get the control database (for advanced queries)
2170
3572
  */
2171
3573
  getControlDatabase() {
2172
3574
  return this.controlDatabase;
2173
3575
  }
3576
+ /**
3577
+ * Open control-network connections right now. A lower-bound proxy for replication
3578
+ * reach: 0 connections ⇒ a control write commits local-only. The private
3579
+ * `committedAlone` write-while-alone seam is defined in terms of this.
3580
+ *
3581
+ * It approximates the precise signal (the block's cluster size), and a caller that
3582
+ * samples it AFTER a write samples a slightly wider window than `committedAlone`
3583
+ * does inside the write itself. It exists so an embedder can warn an owner before a
3584
+ * control write that this machine currently sees none of its siblings, and report
3585
+ * after one that the write may not have travelled — a warning shown unconditionally
3586
+ * is a warning people learn to ignore.
3587
+ */
3588
+ getControlConnectionCount() {
3589
+ return this.controlNode?.getConnections().length ?? 0;
3590
+ }
2174
3591
  /**
2175
3592
  * Force a poll of the strand watcher (for testing)
2176
3593
  */
@@ -2187,26 +3604,45 @@ export class CadreNode {
2187
3604
  // Seed Bootstrap API
2188
3605
  // ============================================================================
2189
3606
  /**
2190
- * Initialize the seed bootstrap service with an authority key.
3607
+ * Initialize the seed bootstrap service with an owner key.
2191
3608
  * Must be called before using seed-related methods that require signing.
2192
3609
  *
2193
- * @param authorityPrivateKey - The authority's private key (base64url encoded)
3610
+ * @param ownerPrivateKey - The owner's private key (base64url encoded)
2194
3611
  */
2195
- initializeSeedBootstrap(authorityPrivateKey) {
3612
+ initializeSeedBootstrap(ownerPrivateKey) {
2196
3613
  if (!this.controlNode || !this.controlDatabase) {
2197
3614
  throw new Error('CadreNode must be started before initializing seed bootstrap');
2198
3615
  }
3616
+ // A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
3617
+ // key an authority of its own party (founder genesis / self-signing owner)
3618
+ // — anchor it in the node-local store. The in-memory set updates
3619
+ // synchronously (see TrustedOwnerStore.trust); only the file persist is
3620
+ // fire-and-forget, logged on failure.
3621
+ // NOTE: non-founder members that also wire seed-bootstrap with their own
3622
+ // derived key (e.g. the phone joiner path in runOwnerGenesis, which needs
3623
+ // it to self-publish its CadrePeer row) self-anchor a key that is not a
3624
+ // party authority. Harmless while such a node never mints an invite: the
3625
+ // store does not replicate, so a node trusting itself grants nothing to
3626
+ // others. But `createInvite` now hands out the anchor's contents as the
3627
+ // invitee's pins, so the moment a non-founder member mints an invite it
3628
+ // exports its own non-authority key as a cadre owner key. If that becomes
3629
+ // reachable (today only cadre-cli/cadre-host owners mint invites), gate this
3630
+ // self-anchor on the actual OwnerKey genesis insert instead.
3631
+ if (this.trustedOwnerStore) {
3632
+ void this.trustedOwnerStore
3633
+ .trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
3634
+ .catch((error) => log('Trusted-owner genesis anchor persist failed: %o', error));
3635
+ }
2199
3636
  this.seedBootstrapService = new SeedBootstrapService({
2200
3637
  partyId: this.config.controlNetwork.partyId,
2201
- authorityPrivateKey,
3638
+ ownerPrivateKey,
2202
3639
  inviteAddressResolver: () => this.resolveInviteAddresses(),
2203
3640
  trustPolicy: this.config.seedTrustPolicy,
3641
+ // Seed trust anchors on the node-local store (seeded just above with this
3642
+ // node's own genesis key), never on the replicated OwnerKey table.
3643
+ ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
2204
3644
  });
2205
- this.seedBootstrapService.setEventCallbacks({
2206
- onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
2207
- onSeedApplied: (partyId, peersAdded) => this.emit('seed:applied', { partyId, peersAdded }),
2208
- onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
2209
- });
3645
+ this.seedBootstrapService.setEventCallbacks(this.seedEventCallbacks());
2210
3646
  this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
2211
3647
  log('Seed bootstrap service initialized');
2212
3648
  }
@@ -2233,7 +3669,10 @@ export class CadreNode {
2233
3669
  return this.getMultiaddrs();
2234
3670
  }
2235
3671
  /**
2236
- * Enumerate the cadre's `CadrePeer` membership.
3672
+ * Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
3673
+ * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
3674
+ * never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
3675
+ * revoked peer is not addressable either.
2237
3676
  */
2238
3677
  async listMembers() {
2239
3678
  if (!this.controlDatabase) {
@@ -2243,11 +3682,105 @@ export class CadreNode {
2243
3682
  }
2244
3683
  /**
2245
3684
  * Probe whether a given peer is a `CadrePeer` member.
3685
+ *
3686
+ * This is the ADDRESSABLE surface ("do I have a dialable address record for this
3687
+ * peer") — it includes this node's own self-published row. Address resolution and
3688
+ * push fan-out use this. A peer whose stamp is retired in `CadreControl.Revocation`
3689
+ * is excluded here too (the filter lives in
3690
+ * {@link ControlDatabase.queryCadrePeers}): revocation removes a peer from the
3691
+ * addressable surface, not only the trust-facing one, so a revoked peer is no
3692
+ * longer dialed, RPC'd, or handed out as an address. The trust-facing gate is
3693
+ * {@link isAuthorizedMember}.
2246
3694
  */
2247
3695
  async isMember(peerId) {
2248
3696
  const members = await this.listMembers();
2249
3697
  return members.some(m => m.peerId === peerId);
2250
3698
  }
3699
+ /**
3700
+ * Enumerate the party's AUTHORIZED members — the trust-facing set, distinct from
3701
+ * the addressable set ({@link listMembers}). A peer is authorized iff ALL hold:
3702
+ *
3703
+ * 1. it is not this node itself — a node publishes its own `CadrePeer` address
3704
+ * row so its dialable address rides in seeds, but "self" is not a peer this
3705
+ * node authorized;
3706
+ * 2. its `CadrePeer` row carries a complete voucher (`StampId`, `VouchOwner`,
3707
+ * `VouchSig` all non-null);
3708
+ * 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
3709
+ * ({@link getTrustedOwnerStore}) — never the replicated `OwnerKey` table,
3710
+ * which any stranger can genesis-pollute; and
3711
+ * 4. `VouchSig` verifies as that owner's signature over the row's voucher
3712
+ * digest ({@link verifyCadrePeerVoucher}), so the anchored owner really
3713
+ * vouched THIS peer id under THIS row's nonce; and
3714
+ * 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
3715
+ * upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
3716
+ * before ANY reader (this predicate included) sees them, so a row resurrected
3717
+ * by replaying the captured admission approval on a node that had not yet
3718
+ * converged on the tombstone (the write-time `NotRevoked` CHECK only sees
3719
+ * local rows) is still inert to every reader that has the tombstone.
3720
+ *
3721
+ * Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
3722
+ * not-yet-enrolled node authorizes no one), a null/partial voucher, an
3723
+ * unanchored `VouchOwner`, or a bad signature all yield "not authorized" —
3724
+ * having an address row is NOT membership. The control-network wake and
3725
+ * strand-address gates consult this set, NOT the addressable one.
3726
+ *
3727
+ * NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
3728
+ * in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
3729
+ * legit member goes un-authorized on readers that only pin the new key. Full
3730
+ * rotation handling (re-vouch on rotate) is the
3731
+ * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
3732
+ */
3733
+ async listAuthorizedMembers() {
3734
+ if (!this.controlDatabase) {
3735
+ throw new Error('CadreNode must be started before listing members');
3736
+ }
3737
+ const selfPeerId = this.peerId?.toString();
3738
+ const rows = await this.controlDatabase.queryCadrePeers();
3739
+ const authorized = rows
3740
+ .filter(row => row.peerId !== selfPeerId && this.hasAnchoredVoucher(row))
3741
+ .map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
3742
+ // A node whose anchor was never seeded (no invite pin, no operator pin, not a
3743
+ // founder) refuses every wake and strand-addr request, which from the outside
3744
+ // looks like an unexplained "non-member" rejection. Say so once per call rather
3745
+ // than leaving the operator to infer it from silence.
3746
+ if (authorized.length === 0 && rows.length > 0 && (this.trustedOwnerStore?.all().size ?? 0) === 0) {
3747
+ log('listAuthorizedMembers: %d CadrePeer row(s) but the node-local trusted-owner anchor is EMPTY — authorizing no one (this node has no invite/operator owner-key pin)', rows.length);
3748
+ }
3749
+ return authorized;
3750
+ }
3751
+ /**
3752
+ * Checks 2–4 of the authorized-membership predicate (see
3753
+ * {@link listAuthorizedMembers}) for one `CadrePeer` row: complete voucher,
3754
+ * `VouchOwner` in the node-local anchor, signature valid over the row's
3755
+ * (PeerId, StampId) voucher digest.
3756
+ *
3757
+ * NOTE: verifies the ed25519 signature on every call (no memo of already-
3758
+ * verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
3759
+ * devices and the gates run per inbound request, so this is cheap today; if
3760
+ * membership or gate traffic ever grows, cache verified triples keyed on the
3761
+ * row's `StampId` (which rotates with every re-vouch).
3762
+ */
3763
+ hasAnchoredVoucher(row) {
3764
+ if (row.stampId === null || row.vouchOwner === null || row.vouchSig === null) {
3765
+ return false;
3766
+ }
3767
+ if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
3768
+ return false;
3769
+ }
3770
+ return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
3771
+ }
3772
+ /**
3773
+ * Probe whether a given peer is an AUTHORIZED party member (see
3774
+ * {@link listAuthorizedMembers}) — the gate the control-network wake and
3775
+ * strand-address responders consult, NOT {@link isMember} (the addressable
3776
+ * surface). Deliberately scans the full membership (one `CadrePeer` query)
3777
+ * rather than adding a single-row read path: cadres are small, and one code
3778
+ * path keeps the predicate impossible to drift from the list.
3779
+ */
3780
+ async isAuthorizedMember(peerId) {
3781
+ const members = await this.listAuthorizedMembers();
3782
+ return members.some(m => m.peerId === peerId);
3783
+ }
2251
3784
  /**
2252
3785
  * Push-wake a hibernating cadre peer over the control network.
2253
3786
  *
@@ -2276,8 +3809,8 @@ export class CadreNode {
2276
3809
  }
2277
3810
  /**
2278
3811
  * Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
2279
- * This is for drone nodes that need to receive seeds without being an authority.
2280
- * Does not require an authority key.
3812
+ * This is for drone nodes that need to receive seeds without being an owner.
3813
+ * Does not require an owner key.
2281
3814
  */
2282
3815
  enableSeedListener() {
2283
3816
  if (!this.controlNode || !this.controlDatabase) {
@@ -2290,15 +3823,15 @@ export class CadreNode {
2290
3823
  }
2291
3824
  this.seedBootstrapService = new SeedBootstrapService({
2292
3825
  partyId: this.config.controlNetwork.partyId,
2293
- // No authority key - this node only receives seeds
3826
+ // No owner key - this node only receives seeds
2294
3827
  inviteAddressResolver: () => this.resolveInviteAddresses(),
2295
3828
  trustPolicy: this.config.seedTrustPolicy,
3829
+ // A listener-only node accepts a wire-delivered seed solely against this
3830
+ // anchor (there is no per-call override on the inbound handler): with no
3831
+ // genesis/invite/operator pin it authorizes nobody, which is the point.
3832
+ ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
2296
3833
  });
2297
- this.seedBootstrapService.setEventCallbacks({
2298
- onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
2299
- onSeedApplied: (partyId, peersAdded) => this.emit('seed:applied', { partyId, peersAdded }),
2300
- onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
2301
- });
3834
+ this.seedBootstrapService.setEventCallbacks(this.seedEventCallbacks());
2302
3835
  this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
2303
3836
  log('Seed listener enabled');
2304
3837
  }
@@ -2308,9 +3841,125 @@ export class CadreNode {
2308
3841
  getSeedBootstrapService() {
2309
3842
  return this.seedBootstrapService;
2310
3843
  }
3844
+ /**
3845
+ * The event callbacks every {@link SeedBootstrapService} this node owns is
3846
+ * wired with — shared by the owner-capable service ({@link initializeSeedBootstrap})
3847
+ * and the listener-only one ({@link enableSeedListener}) so the two cannot drift.
3848
+ *
3849
+ * A seed applied by the INBOUND protocol handler writes no `CadrePeer` row of
3850
+ * its own (it merges the libp2p peer store and dials owners), so the automatic
3851
+ * write-driven refresh never fires for it — yet applying it can ANCHOR a new
3852
+ * owner key, which flips rows already present from unauthorized to authorized.
3853
+ * Hence the explicit refresh here; without it a peer the freshly anchored owner
3854
+ * vouched for stays denied until the next timed cohort reconcile.
3855
+ */
3856
+ seedEventCallbacks() {
3857
+ return {
3858
+ onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
3859
+ onSeedApplied: (partyId, peersAdded, seed) => {
3860
+ this.emit('seed:applied', { partyId, peersAdded });
3861
+ // A wire-delivered seed never passes through the `applySeed` wrapper, so
3862
+ // this is where its owner peers become cold-start bootstrap targets.
3863
+ this.recordSeedBootstrapPeers(seed);
3864
+ void this.refreshMembershipGate('seed-applied');
3865
+ },
3866
+ onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
3867
+ };
3868
+ }
3869
+ /**
3870
+ * Re-materialize the authorized-peer snapshot that the fail-closed per-stream
3871
+ * control-DB gate ({@link authorizeInboundControlStream}) judges against.
3872
+ *
3873
+ * NO `CadrePeer` writer needs to call this: the control database notifies it
3874
+ * after every committed member-row write (`ControlDatabase.mutateCadrePeer`,
3875
+ * wired in {@link start}), which is exactly what makes the refresh automatic
3876
+ * rather than a caller obligation. It stays public for the changes that write
3877
+ * NO row locally and so raise no notification:
3878
+ *
3879
+ * - a membership row that arrived by REPLICATION (otherwise picked up on the
3880
+ * next timed cohort reconcile — bounded staleness by design), and
3881
+ * - a newly anchored trusted owner key ({@link applySeed}), which flips rows
3882
+ * ALREADY present from unauthorized to authorized without touching them.
3883
+ *
3884
+ * Coalescing: marks the snapshot stale and resolves once a refresh that began
3885
+ * after this call has completed, so a caller that awaits it always observes
3886
+ * its own change. Concurrent callers share one read. Idempotent and
3887
+ * best-effort (a failed read keeps the previous snapshot); never rejects.
3888
+ */
3889
+ async refreshMembershipGate(reason = 'external-write') {
3890
+ this.membershipGateDirty = true;
3891
+ if (this.membershipGateDeferDepth > 0) {
3892
+ // Flushed once when the enclosing scope exits.
3893
+ return;
3894
+ }
3895
+ while (this.membershipGateDirty) {
3896
+ // Re-checked after the await: the drain may have taken its last look at
3897
+ // the flag before we set it, in which case we need a fresh one.
3898
+ this.membershipGateDrain ?? (this.membershipGateDrain = this.drainMembershipGate(reason));
3899
+ await this.membershipGateDrain;
3900
+ }
3901
+ }
3902
+ /**
3903
+ * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
3904
+ * until the stale flag stays clear, then release the slot.
3905
+ *
3906
+ * NOTE: correct only because {@link membershipGateDirty} is guaranteed TRUE at
3907
+ * entry — its sole caller sets it synchronously, with no await in between. Were
3908
+ * this ever invoked with the flag already clear, the body would never suspend,
3909
+ * so the `finally` would null the slot BEFORE the caller's `??=` filled it, and
3910
+ * the slot would be left holding an already-settled drain that every later
3911
+ * refresh reuses — an unbreakable await/re-check spin. If a second caller is
3912
+ * ever added, have it mark the flag first (or assert it here).
3913
+ */
3914
+ async drainMembershipGate(reason) {
3915
+ try {
3916
+ while (this.membershipGateDirty) {
3917
+ // Cleared BEFORE the read (not after) so a write landing mid-read marks
3918
+ // the snapshot stale again and earns another pass, and so a throw still
3919
+ // terminates the loop.
3920
+ this.membershipGateDirty = false;
3921
+ await this.refreshAuthorizedControlPeers(reason);
3922
+ }
3923
+ }
3924
+ catch (error) {
3925
+ // Upholds `refreshMembershipGate`'s never-rejects contract. The read helper
3926
+ // already swallows its own failures, so this is defensive — but a rejection
3927
+ // here would otherwise surface out of every awaiting writer.
3928
+ log('membership gate drain (%s) failed — keeping previous snapshot: %o', reason, error);
3929
+ }
3930
+ finally {
3931
+ this.membershipGateDrain = null;
3932
+ }
3933
+ }
3934
+ /**
3935
+ * Collapse a burst of `CadrePeer` writes into ONE gate refresh at scope exit.
3936
+ *
3937
+ * For loops that re-touch many rows (the write-while-alone drains), where a
3938
+ * per-row refresh would mean one full membership read per row for a snapshot
3939
+ * that only has to be correct once the loop settles.
3940
+ *
3941
+ * The depth counter is instance-level, so it also suppresses a genuinely
3942
+ * CONCURRENT external write's refresh for the life of the scope: that writer's
3943
+ * promise resolves before its peer is admitted, and admission lands at scope
3944
+ * exit instead. Acceptable — scope exit is milliseconds away (bounded by the
3945
+ * drain), versus the ~15 s reconcile interval that was the alternative before
3946
+ * any of this existed — but it is the reason these scopes stay short and rare.
3947
+ */
3948
+ async deferMembershipGateRefresh(reason, body) {
3949
+ this.membershipGateDeferDepth++;
3950
+ try {
3951
+ return await body();
3952
+ }
3953
+ finally {
3954
+ this.membershipGateDeferDepth--;
3955
+ if (this.membershipGateDeferDepth === 0 && this.membershipGateDirty) {
3956
+ await this.refreshMembershipGate(reason);
3957
+ }
3958
+ }
3959
+ }
2311
3960
  /**
2312
3961
  * Authorize a new peer to join the cadre.
2313
- * Signs the peer ID with the authority key and inserts into CadrePeer table.
3962
+ * Signs the peer ID with the owner key and inserts into CadrePeer table.
2314
3963
  *
2315
3964
  * @param peerId - The peer ID to authorize
2316
3965
  * @param multiaddrs - Optional multiaddrs for the peer
@@ -2319,13 +3968,15 @@ export class CadreNode {
2319
3968
  if (!this.seedBootstrapService) {
2320
3969
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
2321
3970
  }
3971
+ // The insert notifies the control DB's membership hub, so the per-stream gate
3972
+ // has already admitted the just-vouched peer by the time this resolves.
2322
3973
  await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
2323
3974
  // Queue for re-replication if this committed local-only (no connected cohort).
2324
3975
  this.noteControlWrite(peerId, 'authorize');
2325
3976
  }
2326
3977
  /**
2327
3978
  * Remove a previously-authorized peer from the cadre.
2328
- * Signs the peer ID with the authority key and deletes the CadrePeer row.
3979
+ * Signs the peer ID with the owner key and deletes the CadrePeer row.
2329
3980
  *
2330
3981
  * @param peerId - The peer ID to remove
2331
3982
  */
@@ -2333,13 +3984,15 @@ export class CadreNode {
2333
3984
  if (!this.seedBootstrapService) {
2334
3985
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
2335
3986
  }
3987
+ // The delete notifies the membership hub, so the removed peer is out of the
3988
+ // per-stream gate by the time this resolves.
2336
3989
  await this.seedBootstrapService.removePeer(peerId);
2337
3990
  // Track + loudly flag a delete that committed local-only (security-relevant).
2338
3991
  this.noteControlWrite(peerId, 'remove');
2339
3992
  }
2340
3993
  /**
2341
3994
  * Create a seed from the current control network state.
2342
- * The seed contains peer information and is signed by an authority.
3995
+ * The seed contains peer information and is signed by an owner.
2343
3996
  */
2344
3997
  async createSeed() {
2345
3998
  if (!this.seedBootstrapService) {
@@ -2353,12 +4006,12 @@ export class CadreNode {
2353
4006
  * Validates the seed signature, then evaluates a trust anchor for the signer
2354
4007
  * key (see `SeedTrustPolicy`). An enrollment caller can pass a per-seed
2355
4008
  * `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
2356
- * `CadreInvite.authorityKeys` — so a cold-start node can accept its first
4009
+ * `CadreInvite.ownerKeys` — so a cold-start node can accept its first
2357
4010
  * seed without reconfiguring the service.
2358
4011
  */
2359
4012
  async applySeed(seed, options) {
2360
4013
  if (!this.seedBootstrapService) {
2361
- // Create a temporary service for applying seeds (doesn't need authority key).
4014
+ // Create a temporary service for applying seeds (doesn't need owner key).
2362
4015
  // partyId is the attacker-influenced seed.partyId — it only labels logs; the
2363
4016
  // trust decision rests solely on signerKey vs the anchor set (configured
2364
4017
  // default below, or the per-call options.trustPolicy override).
@@ -2372,13 +4025,41 @@ export class CadreNode {
2372
4025
  const tempService = new SeedBootstrapService({
2373
4026
  partyId: seed.partyId,
2374
4027
  trustPolicy: this.config.seedTrustPolicy,
4028
+ // The anchor is node-scoped, not service-scoped: a throwaway service
4029
+ // must consult (and persist an accepted signer into) the SAME store the
4030
+ // persistent one would, or a cold-start enrollment via this path would
4031
+ // anchor nothing and re-prompt for the pin on every later seed.
4032
+ ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
2375
4033
  });
2376
4034
  if (this.controlNode && this.controlDatabase) {
2377
4035
  tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
2378
4036
  }
2379
- return await tempService.applySeed(seed, options);
4037
+ const tempResult = await tempService.applySeed(seed, options);
4038
+ this.noteAppliedSeed(tempResult, seed);
4039
+ // Not a row write (so nothing notifies): applying a seed can anchor a new
4040
+ // owner key, which flips rows ALREADY present into the authorized set.
4041
+ await this.refreshMembershipGate('applySeed');
4042
+ return tempResult;
4043
+ }
4044
+ const result = await this.seedBootstrapService.applySeed(seed, options);
4045
+ this.noteAppliedSeed(result, seed);
4046
+ await this.refreshMembershipGate('applySeed');
4047
+ return result;
4048
+ }
4049
+ /**
4050
+ * Post-process an {@link applySeed} outcome: retain the seed's owner peers as
4051
+ * cold-start bootstrap targets, and surface a seed that was accepted but whose
4052
+ * every owner dial failed — the node is now seeded yet unconnected, and the
4053
+ * cold-start reconcile branch is what gets it out of that state.
4054
+ */
4055
+ noteAppliedSeed(result, seed) {
4056
+ if (!result.success) {
4057
+ return;
4058
+ }
4059
+ this.recordSeedBootstrapPeers(seed);
4060
+ if (result.ownerDialsAttempted > 0 && result.ownerDialsFailed === result.ownerDialsAttempted) {
4061
+ log('applySeed: seeded but no owner reachable (%d/%d dial(s) failed) — cold-start reconcile will retry', result.ownerDialsFailed, result.ownerDialsAttempted);
2380
4062
  }
2381
- return await this.seedBootstrapService.applySeed(seed, options);
2382
4063
  }
2383
4064
  /**
2384
4065
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
@@ -2429,6 +4110,8 @@ export class CadreNode {
2429
4110
  if (!this.seedBootstrapService) {
2430
4111
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
2431
4112
  }
4113
+ // The drone's `CadrePeer` insert notifies the membership hub on the way to the
4114
+ // seed, so the per-stream gate already admits it here.
2432
4115
  return await this.seedBootstrapService.addDrone(options);
2433
4116
  }
2434
4117
  /**
@@ -2439,7 +4122,12 @@ export class CadreNode {
2439
4122
  if (!this.seedBootstrapService) {
2440
4123
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
2441
4124
  }
2442
- return await this.seedBootstrapService.createInvite(token, expiresIn);
4125
+ const result = await this.seedBootstrapService.createInvite(token, expiresIn);
4126
+ // The invitee dials this node BEFORE it is authorized (dialInvite →
4127
+ // acceptPhone), so hold the inbound connection gate open for strangers for
4128
+ // the invite's validity (or a bounded default when it never expires).
4129
+ this.openEnrollmentWindow(result.invite.expiresAt ?? Date.now() + DEFAULT_ENROLLMENT_WINDOW_MS);
4130
+ return result;
2443
4131
  }
2444
4132
  /**
2445
4133
  * Accept a phone connection using an invite.
@@ -2449,6 +4137,8 @@ export class CadreNode {
2449
4137
  if (!this.seedBootstrapService) {
2450
4138
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
2451
4139
  }
4140
+ // The just-accepted phone opens control-DB streams next; its `CadrePeer` insert
4141
+ // notifies the membership hub, so it is already admitted when this resolves.
2452
4142
  await this.seedBootstrapService.acceptPhone(options, issuedInvite);
2453
4143
  }
2454
4144
  /**
@@ -2459,6 +4149,10 @@ export class CadreNode {
2459
4149
  if (!this.seedBootstrapService) {
2460
4150
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
2461
4151
  }
4152
+ // The service authorizes the phone (a `CadrePeer` insert, which notifies the
4153
+ // membership hub) on the way to the seed, so the per-stream gate admits it
4154
+ // before this resolves — not on the next reconcile, by which time the phone's
4155
+ // own control-DB schema load may have died denied.
2462
4156
  return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
2463
4157
  }
2464
4158
  /**
@@ -2477,7 +4171,7 @@ export class CadreNode {
2477
4171
  return JSON.parse(json);
2478
4172
  }
2479
4173
  /**
2480
- * Dial an authority from an invite (for phone joining via invite).
4174
+ * Dial an owner from an invite (for phone joining via invite).
2481
4175
  */
2482
4176
  async dialInvite(invite) {
2483
4177
  if (!this.seedBootstrapService) {