@serfab/cadre-core 1.13.0 → 1.15.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 (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
package/src/cadre-node.ts CHANGED
@@ -18,11 +18,10 @@ import type {
18
18
  CadreNodeEvents,
19
19
  ControlNetworkSeed,
20
20
  ApplySeedResult,
21
+ SeedAckMessage,
22
+ SeedDeliveryTarget,
21
23
  AddDroneOptions,
22
- AddPhoneOptions,
23
24
  DroneInitResult,
24
- InviteResult,
25
- CadreInvite,
26
25
  OpenInvitation,
27
26
  FormStrandResult,
28
27
  StrandFormationDisclosure,
@@ -35,13 +34,27 @@ import type {
35
34
  DeviceTokenRecord,
36
35
  CadrePeerVoucherFields,
37
36
  CadrePeerRow,
37
+ OwnerKeyRow,
38
+ RevocationRow,
39
+ InvitationChain,
40
+ CadreInviteStatus,
41
+ CadreMachine,
42
+ MachineRemovalPlan,
43
+ MachineRemovalResult,
44
+ RemoveMachineOptions,
45
+ CadreRemoval,
46
+ CadreInvitationReach,
47
+ CreateCadreInvitationOptions,
48
+ CreateCadreInvitationResult,
49
+ RedeemCadreInvitationOptions,
50
+ RedeemCadreInvitationResult,
38
51
  PeerAddressRecord,
39
52
  ResolveDeviceTokenOpts,
40
- PendingJoinRow,
53
+ PendingJoin,
41
54
  PendingJoinStatus
42
55
  } from './types.js';
43
56
  import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
44
- import { sign } from '@optimystic/quereus-plugin-crypto';
57
+ import { generatePrivateKey, sign } from '@optimystic/quereus-plugin-crypto';
45
58
  import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
46
59
  import { strandTransportKey } from './strand-transport-key.js';
47
60
  import {
@@ -64,21 +77,22 @@ import {
64
77
  JoinedStrandSession,
65
78
  KeyStoreJoinedStrandStore,
66
79
  MemoryJoinedStrandStore,
80
+ type JoinedStrandRecord,
67
81
  type JoinedStrandStore,
68
82
  type PartyJoinedStrandLedger
69
83
  } from './joined-strand-store.js';
70
84
  import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
71
85
  import { strandFretPeerAddrs } from './strand-fret-addrs.js';
72
- import { verifyCadrePeerVoucher } from './peer-authorization.js';
73
- import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
86
+ import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
87
+ import { deriveOwnerAnchor } from './owner-anchor-sync.js';
88
+ import { describeMachines, planMachineRemoval, revouchWithinPlan, MachineRemovalRefusedError } from './machine-removal.js';
89
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
74
90
  import {
75
91
  signPeerRecord,
76
92
  verifyPeerRecordSignature,
77
93
  isPeerRecordFresh,
78
94
  orderSignalingFirst,
79
95
  isSignalingAddr,
80
- trailingPeerId,
81
- withTrailingPeerId,
82
96
  currentMemberTrustPolicy,
83
97
  DEFAULT_PEER_RECORD_MAX_AGE_MS,
84
98
  DEFAULT_PEER_RECORD_HEARTBEAT_MS
@@ -103,35 +117,49 @@ import {
103
117
  dialPeerAddrs,
104
118
  SelfRelayOnlyError,
105
119
  CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
120
+ trailingPeerId,
121
+ withTrailingPeerId,
106
122
  type PeerDialBudget
107
123
  } from './peer-dial.js';
108
- import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
124
+ import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
109
125
  import { EnrollmentService } from './enrollment.js';
110
126
  import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
111
- import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
127
+ import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type JoinRequestFields, type RevokedRowRef } from './control-database.js';
128
+ import {
129
+ CadreInviteHandler,
130
+ encodeCadreInvitation,
131
+ redeemInPasses,
132
+ signRedeemRequest,
133
+ type CadreInvitation
134
+ } from './cadre-invite-protocol.js';
112
135
  import { FormationPostApprovalError } from './strand-formation-rejection.js';
113
136
  import {
114
137
  PendingJoinRunner,
115
138
  membershipInvitesToStage,
116
139
  parseStoredDisclosure,
117
- requestedPendingJoin,
118
- type PendingJoinFields
140
+ requestedPendingJoin
119
141
  } from './pending-join-runner.js';
120
142
  import type { ControlRetryAbandonment } from './control-retry.js';
121
- import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
122
- import type { SeedTrustPolicy } from './seed-trust-policy.js';
143
+ import { SeedBootstrapService, mergeSeedPeers, seedDigest, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
144
+ import { claimSecretTrustPolicy, type SeedTrustPolicy } from './seed-trust-policy.js';
145
+ import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
123
146
  import {
124
147
  StrandSolicitationService,
125
148
  type StrandSolicitationServiceOptions
126
149
  } from './strand-solicitation.js';
127
150
  import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
128
- import { selectInvitationSiblingAddrs, type InvitationSibling } from './invitation-bootstrap.js';
151
+ import {
152
+ cadreInvitationReach,
153
+ selectCadreInvitationMembers,
154
+ selectInvitationSiblingAddrs,
155
+ type InvitationSibling
156
+ } from './invitation-bootstrap.js';
129
157
  import {
130
158
  createMembershipConnectionGater,
131
- DEFAULT_ENROLLMENT_WINDOW_MS,
132
159
  UnauthorizedReservationBudget,
133
160
  type InboundConnectionVerdict
134
161
  } from './membership-connection-gater.js';
162
+ import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
135
163
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
136
164
  import { StrandAddrService, collectStrandAddrs, type StrandAddrPeer, type StrandAddrOutcome, type StrandAddrCollection } from './strand-addr-protocol.js';
137
165
  import {
@@ -143,6 +171,7 @@ import {
143
171
  type CircuitRelayTarget
144
172
  } from './delegate-admission.js';
145
173
  import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
174
+ import { nodeTransportsOption } from './node-transports.js';
146
175
  import { resolveRelayServer, type ResolvedRelayServer } from './relay-server.js';
147
176
  import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
148
177
  import {
@@ -187,6 +216,17 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
187
216
  */
188
217
  export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
189
218
 
219
+ /**
220
+ * Default lifetime of an UNTARGETED, OWNER-GRANTING cadre invitation
221
+ * ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
222
+ * it is a bearer credential for admin rights and lives only long enough to be pasted or
223
+ * scanned by the device it was minted for.
224
+ */
225
+ export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
226
+
227
+ /** Default lifetime of every other cadre invitation: targeted, or granting membership only. */
228
+ export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
229
+
190
230
  type EventHandler<T> = (data: T) => void;
191
231
 
192
232
  /**
@@ -235,6 +275,25 @@ function unionAddrs(primary: readonly string[], extra: readonly string[]): strin
235
275
  * canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
236
276
  * them directly (no pre-hash) with the owner private key, returning a base64url signature.
237
277
  */
278
+ /**
279
+ * One membership refresh's invitation chain ({@link InvitationChain}), loaded at most once
280
+ * and shared by the anchor sync (`OwnerKey` rows) and the membership read (`CadrePeer`
281
+ * rows), each of which calls it only when some row it judges is invitation-admitted.
282
+ */
283
+ type InvitationChainSource = () => Promise<InvitationChain>;
284
+
285
+ /**
286
+ * One membership refresh's `Revocation` tombstones, loaded at most once and shared by the
287
+ * anchor sync (`OwnerKey` stamps and signers) and the hang-up of removed peers (`CadrePeer`
288
+ * row keys), each of which calls it only when it has something to judge.
289
+ */
290
+ type RevocationSource = () => Promise<RevocationRow[]>;
291
+
292
+ /** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
293
+ function isInvitationAdmitted(row: { vouchSig: string | null; vouchUsage: string | null }): boolean {
294
+ return row.vouchSig === null && row.vouchUsage !== null;
295
+ }
296
+
238
297
  function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
239
298
  return (message: Uint8Array): string =>
240
299
  sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
@@ -374,11 +433,26 @@ export class CadreNode implements SAppIdLookup {
374
433
  * start() cycle rebuilds it with a fresh caught-up-peer memo.
375
434
  */
376
435
  private controlBackfill: PeerJoinBackfill | null = null;
436
+ /**
437
+ * Devices whose invitation redemption this member has verified and is admitting right now,
438
+ * each with its count of overlapping redemptions. The control backfill's gate and the
439
+ * control cohort choice (Optimystic's `isNetworkMember`) count them as members, for that
440
+ * window only ({@link admitRedeemingDevice}); the per-stream gate never does.
441
+ */
442
+ private readonly admittingDevices = new Map<string, number>();
377
443
  private strandWatcher: StrandWatcher | null = null;
378
444
  private strandManager: StrandInstanceManager;
379
445
  private hibernationManager: HibernationManager;
380
446
  private enrollmentService: EnrollmentService;
381
447
  private seedBootstrapService: SeedBootstrapService | null = null;
448
+ /**
449
+ * The claim-secret trust policy of a node started with `config.claim`, built once by
450
+ * {@link initializeClaimPolicy} over {@link trustedOwnerStore} and handed to every
451
+ * seed service this node constructs ({@link inboundSeedTrustPolicy}). Kept across
452
+ * stop()→start() like the anchor it writes: its in-process latch and failure window
453
+ * are per node, not per seed service. Null when the node has no claim secret.
454
+ */
455
+ private claimTrustPolicy: SeedTrustPolicy | null = null;
382
456
  private strandSolicitationService: StrandSolicitationService | null = null;
383
457
  /**
384
458
  * The service whose formation handler is registered on the control node. Trails
@@ -397,6 +471,13 @@ export class CadreNode implements SAppIdLookup {
397
471
  * stops conflating control addresses with strand seeding.
398
472
  */
399
473
  private strandAddrService: StrandAddrService | null = null;
474
+ /**
475
+ * Cadre invitation redemption handler (`/sereus/cadre-invite/1.0.0`): a device holding an
476
+ * invitation dials this machine and proves possession; the handler seats the row and
477
+ * admits the device against this node's control database. Registered by {@link start}
478
+ * once that database is up, on every node.
479
+ */
480
+ private cadreInviteHandler: CadreInviteHandler | null = null;
400
481
  /**
401
482
  * Server-side push-wake fan-out. Constructed by {@link start} only when
402
483
  * `config.push` (an injected `PushNotifier` + policy) is present — without it
@@ -406,6 +487,8 @@ export class CadreNode implements SAppIdLookup {
406
487
  private pushFanoutService: PushFanoutService | null = null;
407
488
  /** Backing field for the {@link running} / {@link isRunning} getters. */
408
489
  private _running = false;
490
+ /** The {@link stop} in progress, or null; a concurrent `stop()` awaits it. */
491
+ private stopInFlight: Promise<void> | null = null;
409
492
  /**
410
493
  * The control database's own raw storage (cache-wrapped), resolved once per
411
494
  * `start()` and released in {@link cleanup}. Owning it is what keeps the
@@ -508,26 +591,11 @@ export class CadreNode implements SAppIdLookup {
508
591
  /**
509
592
  * Most-recently pushed invite addresses (see {@link setInviteAddresses}).
510
593
  * When non-null these take priority over `libp2pNode.getMultiaddrs()` when
511
- * minting invites — the host pushes NAT-resolved addresses here so the
512
- * control-network node never needs to dial back to the manager.
594
+ * minting cadre invitations — the host pushes NAT-resolved addresses here so
595
+ * the control-network node never needs to dial back to the manager.
513
596
  */
514
597
  private latestInviteAddresses: string[] | null = null;
515
598
 
516
- /**
517
- * Epoch ms until which the control-network inbound gate admits
518
- * not-yet-authorized peers (see {@link admitInboundControlConnection} /
519
- * {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
520
- * {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
521
- * inside an outstanding invite's validity must not strand the invitee.
522
- *
523
- * NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
524
- * the owner re-mints or the invitee is authorized. Fine while invites are
525
- * short-lived and redeemed promptly; if long-lived invites or restart-prone
526
- * hosts become normal, persist the window (or derive it from the issued-invite
527
- * records `SeedBootstrapService` already keeps).
528
- */
529
- private enrollmentWindowUntil = 0;
530
-
531
599
  /**
532
600
  * Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
533
601
  * {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
@@ -536,13 +604,15 @@ export class CadreNode implements SAppIdLookup {
536
604
 
537
605
  /**
538
606
  * Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
539
- * {@link listAuthorizedMembers} result), consulted by the per-stream
540
- * control-DB gate ({@link authorizeInboundControlStream}). That predicate
541
- * runs on EVERY inbound Optimystic control-DB stream and must never await a
542
- * control-DB read: those reads pull blocks over the very protocols the
543
- * predicate gates, which closes a circular wait that ends in mutual denial
544
- * (the upstream gate is fail-closed on timeout, unlike the fail-open
545
- * connection gater). So the set is refreshed OUT OF BAND instead — after
607
+ * {@link listAuthorizedMembers} result), consulted through
608
+ * {@link isControlNetworkMember} by the per-stream control-DB gate
609
+ * ({@link authorizeInboundControlStream}) and by Optimystic's cohort choice
610
+ * (`isNetworkMember`). The stream gate runs on EVERY inbound Optimystic
611
+ * control-DB stream and must never await a control-DB read: those reads pull
612
+ * blocks over the very protocols the predicate gates, which closes a circular
613
+ * wait that ends in mutual denial (the upstream gate is fail-closed on
614
+ * timeout, unlike the fail-open connection gater). Optimystic's predicate is
615
+ * synchronous by contract. So the set is refreshed OUT OF BAND instead — after
546
616
  * start, on every {@link reconcileControlCohort} pass (15s cadence), and
547
617
  * immediately after every LOCAL membership mutation so a just-vouched peer
548
618
  * is admitted without waiting for the timer. That last refresh is AUTOMATIC:
@@ -551,7 +621,11 @@ export class CadreNode implements SAppIdLookup {
551
621
  * writer has to remember. A change that arrives by REPLICATION is picked up
552
622
  * on the next timed refresh — bounded staleness, acceptable because this gate
553
623
  * is defense in depth: rows an unadmitted peer manages to write are still
554
- * disbelieved at read time by the voucher-anchored predicate.
624
+ * disbelieved at read time by the voucher-anchored predicate. For cohort
625
+ * choice the same window means a member whose row arrived by replication is
626
+ * left out of this node's control-block cohorts until that refresh.
627
+ * Optimystic reads the predicate live on every lookup, so replacing this set
628
+ * is the whole re-evaluation; there is no per-peer reclassify call to make.
555
629
  */
556
630
  private authorizedControlPeers: Set<string> = new Set();
557
631
 
@@ -565,6 +639,18 @@ export class CadreNode implements SAppIdLookup {
565
639
  private membershipGateDrain: Promise<void> | null = null;
566
640
  private membershipGateDeferDepth = 0;
567
641
 
642
+ /**
643
+ * This node's own `CadrePeer` row as a membership refresh last read it live, or null until
644
+ * one has. Reporting a removal needs it ({@link noteOwnRemoval}). Kept across
645
+ * stop()→start(), like {@link removal}.
646
+ */
647
+ private ownLiveRow: CadrePeerRow | null = null;
648
+ /**
649
+ * The removal this node learned of ({@link removedFromCadre}), or null. Set once and never
650
+ * cleared, so `cadre:removed` fires once per node object.
651
+ */
652
+ private removal: CadreRemoval | null = null;
653
+
568
654
  /**
569
655
  * In-memory delegate admission grants (see `delegate-admission.ts`): the
570
656
  * strand-node transport peerIds this party's members have announced over the
@@ -594,10 +680,8 @@ export class CadreNode implements SAppIdLookup {
594
680
  * Does this node's CONTROL libp2p run the circuit-relay server, and with which
595
681
  * init? Resolved once from `network` and `profile` by the same function every
596
682
  * strand node's build uses (`relay-server.ts`). Read by
597
- * {@link buildControlNodeOptions} (which configures the server from it),
598
- * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch must
599
- * agree with whether a reservation is even servable here), and the
600
- * unauthorized budget above (whose TTL is the server's).
683
+ * {@link buildControlNodeOptions} (which configures the server from it) and
684
+ * the unauthorized budget above (whose TTL is the server's).
601
685
  */
602
686
  private readonly relayServer: ResolvedRelayServer;
603
687
 
@@ -709,7 +793,7 @@ export class CadreNode implements SAppIdLookup {
709
793
 
710
794
  /**
711
795
  * Invite keys of the membership invitations this process has staged, by
712
- * {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
796
+ * {@link adoptFormationMembershipInvite} or from a `JoinSuccess` row
713
797
  * ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
714
798
  * staged again by the next pending-join pass. Same lifetime as
715
799
  * {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
@@ -863,9 +947,11 @@ export class CadreNode implements SAppIdLookup {
863
947
  * When this node last PUBLISHED its own `CadrePeer` address record (inserted or
864
948
  * refreshed), or null if it never has in this session. Stamped by
865
949
  * {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
866
- * from — the boot pass, the heartbeat, an address change, a drain or an explicit call.
950
+ * from — the boot pass, the heartbeat, an address change, a drain, a reconcile pass or an
951
+ * explicit call.
867
952
  *
868
- * Read by {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
953
+ * Read by {@link publishFirstSelfRecord}, which stops trying once it is set, and by
954
+ * {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
869
955
  * attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
870
956
  * machine in the party is already discarding this node's address as stale.
871
957
  */
@@ -1108,12 +1194,22 @@ export class CadreNode implements SAppIdLookup {
1108
1194
  // store fails closed before a node is created.
1109
1195
  await this.resolveIdentityKey();
1110
1196
 
1197
+ // A conflicting `claim` is refused BEFORE the anchor is built: the store below
1198
+ // writes the config pins, and on a persistent anchor a pin written ahead of
1199
+ // this refusal would survive the failed start and mark the node claimed by it.
1200
+ this.validateClaimConfig();
1201
+
1111
1202
  // Bring up the node-local trusted-owner anchor (and seed config pins)
1112
1203
  // before any network bring-up: a mis-scoped injected store fails closed
1113
1204
  // here, and out-of-band pins are anchored before the first seed/peer
1114
1205
  // interaction could consult them.
1115
1206
  await this.initializeTrustedOwnerStore();
1116
1207
 
1208
+ // A node started with a claim secret gets its trust policy here, over the anchor
1209
+ // just built; a malformed secret fails closed before any network bring-up, like
1210
+ // a mis-scoped anchor above.
1211
+ this.initializeClaimPolicy();
1212
+
1117
1213
  // Bring up the node-local cold-start bootstrap-peer store alongside the
1118
1214
  // anchor, and for the same two reasons: a mis-scoped injected store fails
1119
1215
  // closed before any network bring-up, and the retained dial targets are
@@ -1159,6 +1255,9 @@ export class CadreNode implements SAppIdLookup {
1159
1255
  this.controlNode = await this.createControlNode();
1160
1256
  timing('[start] createControlNode: %dms', Math.round(performance.now() - t0));
1161
1257
  log('Control node started with ID: %s', this.controlNode.peerId.toString());
1258
+ // Before anything else can happen on the node: the quiet period above holds every
1259
+ // connection off, so no stream can arrive ahead of the guard.
1260
+ this.installControlProtocolGuard(this.controlNode);
1162
1261
 
1163
1262
  // Extract coordinatedRepo from the node (attached by createLibp2pNode)
1164
1263
  const coordinatedRepo = (this.controlNode as Libp2pNodeWithRepo).coordinatedRepo;
@@ -1234,11 +1333,10 @@ export class CadreNode implements SAppIdLookup {
1234
1333
  this.hibernationManager.start();
1235
1334
 
1236
1335
  // Register the control-network push-wake receiver: a same-cadre peer can
1237
- // signal us to bring a hibernating strand online. Gated on AUTHORIZED
1238
- // membership (not the addressable surface); the wake routes through the same
1239
- // path as a local wake.
1336
+ // signal us to bring a hibernating strand online. The protocol guard admits
1337
+ // only AUTHORIZED members (not the addressable surface); the wake routes
1338
+ // through the same path as a local wake.
1240
1339
  this.strandWakeService = new StrandWakeService({
1241
- isMember: (peerId) => this.isAuthorizedMember(peerId),
1242
1340
  getStrand: (strandId) => this.strandManager.getInstance(strandId),
1243
1341
  wake: (strandId) => this.wakeStrand(strandId),
1244
1342
  });
@@ -1246,14 +1344,13 @@ export class CadreNode implements SAppIdLookup {
1246
1344
 
1247
1345
  // Register the control-network strand-address responder: a same-cadre peer
1248
1346
  // resolving a strand's bootstrap seed asks us for our live strand-network
1249
- // multiaddrs (its CadrePeer row only knows our *control* address). Gated on
1250
- // AUTHORIZED membership; answers only for strands we are actively meshing.
1251
- // The same RPC doubles as the delegate-announce channel: a member's request
1252
- // may carry the derived peerId its strand node runs as, and we record an
1253
- // admission grant so our connection gate (and thus our relay server, when
1254
- // enabled) admits it — see delegate-admission.ts.
1347
+ // multiaddrs (its CadrePeer row only knows our *control* address). The
1348
+ // protocol guard admits only AUTHORIZED members; answers only for strands we
1349
+ // are actively meshing. The same RPC doubles as the delegate-announce channel:
1350
+ // a member's request may carry the derived peerId its strand node runs as, and
1351
+ // we record an admission grant so our connection gate (and thus our relay
1352
+ // server, when enabled) admits it — see delegate-admission.ts.
1255
1353
  this.strandAddrService = new StrandAddrService({
1256
- isMember: (peerId) => this.isAuthorizedMember(peerId),
1257
1354
  getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
1258
1355
  onDelegateAnnounce: (announcer, strandId, delegate) =>
1259
1356
  this.grantDelegateAdmission(announcer, strandId, delegate),
@@ -1266,6 +1363,24 @@ export class CadreNode implements SAppIdLookup {
1266
1363
  // so the responder never reads a database that cannot answer yet.
1267
1364
  await this.installDefaultFormationResponder();
1268
1365
 
1366
+ // Answer cadre invitation redemptions from now on: a device holding an invitation
1367
+ // dials any member machine, this one included, and proves possession in-protocol.
1368
+ // After the control database is up, since the handler seats and redeems against it.
1369
+ this.cadreInviteHandler = new CadreInviteHandler({
1370
+ partyId: this.config.controlNetwork.partyId,
1371
+ store: this.controlDatabase,
1372
+ admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
1373
+ });
1374
+ await this.cadreInviteHandler.register(this.controlNode);
1375
+
1376
+ // A node waiting to be claimed listens for its claim seed from the start: nobody
1377
+ // else will turn the listener on, and a node no seed can reach cannot be claimed.
1378
+ // Before `control:connected`, so an embedder that reports "awaiting claim" on that
1379
+ // event is already reachable on `/sereus/seed/1.0.0`.
1380
+ if (this.config.claim) {
1381
+ await this.enableSeedListener();
1382
+ }
1383
+
1269
1384
  // Server-side push-wake fan-out: only when push is configured.
1270
1385
  if (this.config.push) {
1271
1386
  this.pushFanoutService = this.buildPushFanout(this.config.push);
@@ -1354,11 +1469,26 @@ export class CadreNode implements SAppIdLookup {
1354
1469
  * Stop the cadre node
1355
1470
  */
1356
1471
  async stop(): Promise<void> {
1472
+ // A second call while one is in flight joins it rather than running cleanup() twice
1473
+ // concurrently: `_running` stays true until cleanup() ends, and an event raised by a
1474
+ // refresh still in flight (`cadre:removed`, whose embedder stops the node) can reach a
1475
+ // handler that calls stop() mid-teardown.
1476
+ if (this.stopInFlight) {
1477
+ return this.stopInFlight;
1478
+ }
1357
1479
  if (!this._running) {
1358
1480
  log('CadreNode not running');
1359
1481
  return;
1360
1482
  }
1483
+ this.stopInFlight = this.stopRunning();
1484
+ try {
1485
+ await this.stopInFlight;
1486
+ } finally {
1487
+ this.stopInFlight = null;
1488
+ }
1489
+ }
1361
1490
 
1491
+ private async stopRunning(): Promise<void> {
1362
1492
  log('Stopping CadreNode');
1363
1493
  await this.cleanup();
1364
1494
  this.emit('control:disconnected', undefined);
@@ -1446,15 +1576,22 @@ export class CadreNode implements SAppIdLookup {
1446
1576
 
1447
1577
  /**
1448
1578
  * Construct (or adopt) the node-local trusted-owner anchor and seed the
1449
- * out-of-band pinned keys from `config.trustedOwners`. The store is NEVER
1450
- * sourced from the replicated control DB — its entries come only from
1451
- * genesis self-trust ({@link initializeSeedBootstrap}), config pins (here),
1452
- * or runtime enrollment pins ({@link trustOwnerKeys}).
1579
+ * out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
1580
+ * only from out of band — genesis self-trust ({@link initializeSeedBootstrap}),
1581
+ * config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
1582
+ * claim; everything else in it is derived from that base along verifiable
1583
+ * chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
1584
+ * never read from the table directly.
1453
1585
  *
1454
1586
  * Idempotent across stop()→start(): the store instance is kept, and
1455
1587
  * re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
1456
1588
  * idempotent). An injected store scoped to a different party is a
1457
1589
  * configuration error (fail closed before any network bring-up).
1590
+ *
1591
+ * NOTE: a config pin of a key the owners have since REMOVED is re-anchored here at
1592
+ * every start and removed again by the first sync (`refreshMembershipGate('start')`,
1593
+ * fired without await from {@link start}), so such a pin is honoured for at most one
1594
+ * membership refresh per start; dropping the pin from the config ends that.
1458
1595
  */
1459
1596
  private async initializeTrustedOwnerStore(): Promise<void> {
1460
1597
  const { trustedOwners } = this.config;
@@ -1478,6 +1615,93 @@ export class CadreNode implements SAppIdLookup {
1478
1615
  }
1479
1616
  }
1480
1617
 
1618
+ /**
1619
+ * Refuse a `config.claim` that sits beside another cold-start trust source. A node
1620
+ * has exactly ONE, so `claim` with a non-empty `trustedOwners.pinnedKeys` or a
1621
+ * `seedTrustPolicy` is refused here rather than silently letting one of them win.
1622
+ * Asserted at this seam, not by a type: the embedders build `CadreNodeConfig` as one
1623
+ * object literal with every optional field present, which a discriminated union over
1624
+ * the whole config would not express.
1625
+ *
1626
+ * Runs BEFORE {@link initializeTrustedOwnerStore}, which writes the config pins into
1627
+ * the anchor: on a persistent anchor a pin written ahead of this refusal would outlive
1628
+ * the failed start, and the node, restarted with the pin dropped from its config and
1629
+ * `claim` kept, would read as claimed by that pin and never accept its claimant.
1630
+ */
1631
+ private validateClaimConfig(): void {
1632
+ if (!this.config.claim) {
1633
+ return;
1634
+ }
1635
+ if ((this.config.trustedOwners?.pinnedKeys?.length ?? 0) > 0) {
1636
+ throw new Error(
1637
+ 'CadreNodeConfig: `claim` and `trustedOwners.pinnedKeys` are both set — a node has one ' +
1638
+ 'cold-start trust source; a node waiting to be claimed anchors its claimant, not a pin'
1639
+ );
1640
+ }
1641
+ if (this.config.seedTrustPolicy) {
1642
+ throw new Error(
1643
+ 'CadreNodeConfig: `claim` and `seedTrustPolicy` are both set — a node has one cold-start ' +
1644
+ 'trust source; the claim secret supplies the seed trust policy itself'
1645
+ );
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * Build {@link claimTrustPolicy} from `config.claim`, once per node (a restart keeps
1651
+ * the policy, as {@link initializeTrustedOwnerStore} keeps the anchor it writes).
1652
+ * Runs after the anchor exists and before any network bring-up, so a malformed secret
1653
+ * fails `start()` closed with nothing to tear down; `parseClaimSecret` names the
1654
+ * problem and never echoes the value. The conflicts with the other trust sources are
1655
+ * {@link validateClaimConfig}'s, checked before the anchor is written.
1656
+ */
1657
+ private initializeClaimPolicy(): void {
1658
+ const { claim } = this.config;
1659
+ if (!claim || this.claimTrustPolicy) {
1660
+ return;
1661
+ }
1662
+ // Unreachable in practice: start() builds the store just above. Guarded so the
1663
+ // policy can never be built over a missing anchor and silently claim into nothing.
1664
+ if (!this.trustedOwnerStore) {
1665
+ throw new Error('CadreNode: the trusted-owner anchor must exist before the claim policy is built');
1666
+ }
1667
+ this.claimTrustPolicy = claimSecretTrustPolicy({
1668
+ secret: parseClaimSecret(claim.secret),
1669
+ trustedOwners: this.trustedOwnerStore,
1670
+ recordClaim: claim.record,
1671
+ onClaimed: (accepted) => this.emit('claim:accepted', accepted),
1672
+ });
1673
+ log('Node starts awaiting a claim; start() enables the seed listener once the control node is up');
1674
+ }
1675
+
1676
+ /**
1677
+ * Is this node waiting to be claimed: started with `config.claim` and owned by nobody
1678
+ * yet (empty anchor)? Read live from the anchor every time, as
1679
+ * {@link admitControlPeerUnconditionally} reads it per stream: the anchor is the one
1680
+ * source of truth for "claimed", and a cached flag would need its own invalidation
1681
+ * from the policy's persist, the rollback and a restart. False on a node with no claim
1682
+ * secret, on a claimed one (restarted with the secret still set or not), and before
1683
+ * {@link start} has built the anchor.
1684
+ *
1685
+ * While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
1686
+ * and refuses them control-database streams and cohort places ({@link isControlNetworkMember})
1687
+ * and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
1688
+ * no siblings to replicate from and must not become a public relay while it waits.
1689
+ * cadre-cli reports this on `/status`; `claim:accepted` is the event form.
1690
+ */
1691
+ isAwaitingClaim(): boolean {
1692
+ return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
1693
+ }
1694
+
1695
+ /**
1696
+ * The removal from the cadre this node has learned of, or null: the query form of
1697
+ * `cadre:removed`, for an embedder that subscribed after it fired. Kept once set, across
1698
+ * stop()→start() and a later re-admission, because the embedder acts on it once (stops this
1699
+ * node, deletes the cadre's data); a re-added machine joins again from a fresh node.
1700
+ */
1701
+ removedFromCadre(): CadreRemoval | null {
1702
+ return this.removal;
1703
+ }
1704
+
1481
1705
  /**
1482
1706
  * Construct (or adopt) the node-local cold-start bootstrap-peer store (see
1483
1707
  * {@link bootstrapPeerStore}). Synchronous: an injected store has already
@@ -1617,6 +1841,41 @@ export class CadreNode implements SAppIdLookup {
1617
1841
  return this.strandNetworkStateStore;
1618
1842
  }
1619
1843
 
1844
+ /**
1845
+ * The strands this machine joined from another party and has not published party-wide
1846
+ * (see {@link CadreNodeConfig.joinedStrands}). Publication needs a control connection,
1847
+ * so a one-node party's joins live here until it holds one; `exportStrandBundle` reads
1848
+ * it beside the `JoinedStrand` table.
1849
+ *
1850
+ * @throws when the node is not started.
1851
+ */
1852
+ listUnpublishedJoinedStrands(): Promise<JoinedStrandRecord[]> {
1853
+ if (!this.joinedStrands) {
1854
+ throw new Error('CadreNode must be started before listing its unpublished joined strands');
1855
+ }
1856
+ return this.joinedStrands.unpublishedRecords();
1857
+ }
1858
+
1859
+ /**
1860
+ * The strand-network addresses a formation carried for `strandId` (see
1861
+ * {@link formationStrandAddrs}), as kept: attributed per peer. Empty when none were
1862
+ * carried or the strand's peers were forgotten. A copy; the node keeps its own list.
1863
+ */
1864
+ getCarriedStrandAddrs(strandId: string): string[] {
1865
+ return [...(this.formationStrandAddrs.get(strandId) ?? [])];
1866
+ }
1867
+
1868
+ /**
1869
+ * Seat `addrs` as the addresses a formation carried for `strandId`, exactly as a
1870
+ * formation of this node's own would ({@link recordFormationStrandAddrs}): a strand
1871
+ * move seats the exporting node's list here so the first launch has a cross-party
1872
+ * seed. The usual rules apply: a list naming a peer replaces the strand's earlier
1873
+ * one, and a list naming none records nothing.
1874
+ */
1875
+ recordCarriedStrandAddrs(strandId: string, addrs: readonly string[]): Promise<void> {
1876
+ return this.recordFormationStrandAddrs(strandId, addrs, 'recordCarriedStrandAddrs');
1877
+ }
1878
+
1620
1879
  /**
1621
1880
  * The node-local trusted-owner anchor (null before {@link start}). This is
1622
1881
  * the set the authorized-membership predicate and seed-trust anchor consult —
@@ -1628,22 +1887,23 @@ export class CadreNode implements SAppIdLookup {
1628
1887
 
1629
1888
  /**
1630
1889
  * Persist out-of-band-established owner keys into the node-local anchor —
1631
- * the runtime enrollment seam: call with `CadreInvite.ownerKeys` when
1632
- * redeeming an invite (BEFORE the first `applySeed`, so the anchor already
1633
- * holds the pins when seed trust consults it), or with an operator-supplied
1634
- * pin. Idempotent. ('genesis' provenance is reserved for the node's own
1635
- * founding key, seeded internally by {@link initializeSeedBootstrap}.)
1890
+ * the runtime enrollment seam: {@link redeemCadreInvitation} calls it with the
1891
+ * cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
1892
+ * holds the pins when the reply and the rows that follow are judged), and an
1893
+ * embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
1894
+ * provenance is reserved for the node's own founding key, seeded internally by
1895
+ * {@link initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
1636
1896
  *
1637
1897
  * Validates every key's shape before trusting any of them (all-or-nothing):
1638
1898
  * a malformed entry anywhere in `keys` rejects the whole call before a
1639
- * single key is anchored. For the invite route this means a `CadreInvite`
1899
+ * single key is anchored. For the invitation route this means a bundle
1640
1900
  * carrying one malformed `ownerKeys` entry fails the redemption outright —
1641
1901
  * consistent with the anchor's existing whole-record-or-nothing policy for
1642
1902
  * a corrupt persisted entry (see `trusted-owner-store.ts`'s
1643
1903
  * `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
1644
1904
  * and leaving the caller to notice a key went missing.
1645
1905
  */
1646
- async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void> {
1906
+ async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis' | 'chain'>): Promise<void> {
1647
1907
  if (!this.trustedOwnerStore) {
1648
1908
  throw new Error('CadreNode must be started before trusting owner keys');
1649
1909
  }
@@ -1658,19 +1918,24 @@ export class CadreNode implements SAppIdLookup {
1658
1918
  }
1659
1919
 
1660
1920
  /**
1661
- * The control libp2p node's Optimystic network name — ONE binding for every
1662
- * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
1663
- * `/optimystic/<networkName>/...`), so the node options and the block-transfer
1664
- * protocol prefix the control backfill dials can never drift apart.
1665
- *
1666
- * NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
1667
- * today because a party id is locally configured rather than replicated in, and both
1668
- * ends of a connection derive this string identically — an odd party id yields an odd
1669
- * but consistent protocol id, not a mismatch or an escaped name. If a party id ever
1670
- * arrives from the network, encode it here as the storage scope key already does.
1921
+ * Refuse non-members on every members-only protocol of the control node, at one seam
1922
+ * (`control-protocol-guard.ts`). Wake and strand-addr are judged by the live
1923
+ * {@link isAuthorizedMember}, bounded by the asker's own attempt deadline at this
1924
+ * node's declared link; everything a control-database read depends on is judged by
1925
+ * the snapshot ({@link authorizeInboundControlStream}).
1671
1926
  */
1927
+ private installControlProtocolGuard(controlNode: Libp2p): void {
1928
+ installControlProtocolGuard(controlNode, {
1929
+ partyId: this.config.controlNetwork.partyId,
1930
+ isMemberLive: (peerId) => this.isAuthorizedMember(peerId),
1931
+ isMemberSnapshot: (peerId, protocol) => this.authorizeInboundControlStream(peerId, protocol),
1932
+ liveDecisionTimeoutMs: relayedRequestBudgetMs(this.config.network?.linkRoundTripMs),
1933
+ });
1934
+ }
1935
+
1936
+ /** This node's control network name ({@link controlNetworkName}, the one binding for it). */
1672
1937
  private controlNetworkName(): string {
1673
- return `control-${this.config.controlNetwork.partyId}`;
1938
+ return controlNetworkName(this.config.controlNetwork.partyId);
1674
1939
  }
1675
1940
 
1676
1941
  /**
@@ -1698,6 +1963,8 @@ export class CadreNode implements SAppIdLookup {
1698
1963
  * reconnect re-runs identify), and — because the production join order is
1699
1964
  * connect-then-authorize — on every committed membership change via
1700
1965
  * {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
1966
+ * The one non-member the gate admits is a device whose invitation
1967
+ * redemption this member is admitting ({@link admitRedeemingDevice}).
1701
1968
  *
1702
1969
  * No-ops (logged) when the embedder configured no control storage or the
1703
1970
  * node exposes no key network — the backfill would have nothing to read or
@@ -1728,7 +1995,7 @@ export class CadreNode implements SAppIdLookup {
1728
1995
  // under — derived from the same networkName binding the node options
1729
1996
  // used, never re-spelled here.
1730
1997
  protocolPrefix: `/optimystic/${networkName}`,
1731
- authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
1998
+ authorizePeer: async (peerId) => this.admittingDevices.has(peerId) || await this.isAuthorizedMember(peerId)
1732
1999
  }, {
1733
2000
  // A shorter settle window than the strand default (1000 ms): the control
1734
2001
  // store is small (a party's membership — dozens of blocks), a re-push is
@@ -1755,6 +2022,60 @@ export class CadreNode implements SAppIdLookup {
1755
2022
  this.controlBackfill.start();
1756
2023
  }
1757
2024
 
2025
+ /**
2026
+ * Admit a device whose invitation redemption has verified to this member's control
2027
+ * replication until the returned release is called (the handler's `admitDevice`;
2028
+ * `CadreInviteHandler.admitDeviceIfLive` says why the redemption's writes need it): push it
2029
+ * this member's control store and wait for the push, with the device counted as a member by
2030
+ * the backfill's gate and by the control cohort choice ({@link admittingDevices}), so the
2031
+ * seat and the admission that follow are written to the device too. A failed push releases
2032
+ * at once and rethrows: in the cohort without the store, the device would refuse the
2033
+ * commits and tear the writes. With no backfill on this node nothing is admitted.
2034
+ *
2035
+ * Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
2036
+ * `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
2037
+ * before the device was added here, and `catchUpPeer` would answer that run's in-flight
2038
+ * status with an empty result.
2039
+ */
2040
+ private async admitRedeemingDevice(peerId: string): Promise<() => void> {
2041
+ const backfill = this.controlBackfill;
2042
+ if (!backfill) {
2043
+ log('No control backfill on this node; device %s is neither caught up nor admitted before its admission', peerId);
2044
+ return () => undefined;
2045
+ }
2046
+ // NOTE: counted in before the push, because the backfill's gate must admit the device to
2047
+ // push to it; so for the push's length any other control write this member makes includes
2048
+ // a device without the base revisions, which refuses it and the write retries. If that
2049
+ // shows up as retried control writes during redemptions, count the device into the cohort
2050
+ // choice only once the push resolves (a second set beside this one).
2051
+ const release = this.countAdmittingDevice(peerId);
2052
+ try {
2053
+ const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
2054
+ log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d',
2055
+ peerId, result.offered, result.accepted, result.rejected.length);
2056
+ return release;
2057
+ } catch (error) {
2058
+ release();
2059
+ throw error;
2060
+ }
2061
+ }
2062
+
2063
+ /** Count one more redemption admitting `peerId`; the returned release undoes it, once. */
2064
+ private countAdmittingDevice(peerId: string): () => void {
2065
+ this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
2066
+ let released = false;
2067
+ return () => {
2068
+ if (released) return;
2069
+ released = true;
2070
+ const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
2071
+ if (remaining > 0) {
2072
+ this.admittingDevices.set(peerId, remaining);
2073
+ } else {
2074
+ this.admittingDevices.delete(peerId);
2075
+ }
2076
+ };
2077
+ }
2078
+
1758
2079
  /**
1759
2080
  * The control database's cache-wrapped raw storage, resolved from
1760
2081
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -1903,7 +2224,13 @@ export class CadreNode implements SAppIdLookup {
1903
2224
  }),
1904
2225
  arachnode: { enableRingZulu: profile === 'storage' },
1905
2226
  ...(identityKey && { privateKey: identityKey }),
1906
- ...(network?.transports && { transports: network.transports }),
2227
+ // With `network.relayAddrs` set, built with libp2p's relay discovery off (given a
2228
+ // transport list to wrap — `node-transports.ts`), so the supervisor
2229
+ // `driveControlRelayReservation` starts is the only thing that fills the search slot:
2230
+ // otherwise a member's control node running the relay server, which this node
2231
+ // identifies (same party namespace), can take it first (`relay-reservation.ts`,
2232
+ // "WHY LIBP2P'S RELAY DISCOVERY IS SWITCHED OFF").
2233
+ ...nodeTransportsOption(network, transportOptions, (network?.relayAddrs ?? []).length > 0),
1907
2234
  ...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
1908
2235
  // Always present, unlike the conditional spreads around it: an absent
1909
2236
  // `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
@@ -1922,7 +2249,9 @@ export class CadreNode implements SAppIdLookup {
1922
2249
  ...optimysticDialLimits(network?.linkRoundTripMs),
1923
2250
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1924
2251
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1925
- // then. Spread NEXT to `transports` because the two answer the same question.
2252
+ // then. Spread NEXT to `transports` because the two answer the same question, and a
2253
+ // no-op whenever a list is passed there — with `relayAddrs` set that list comes from
2254
+ // `network.defaultTransports` when the embedder gave none, on the same switch.
1926
2255
  ...transportOptions,
1927
2256
  // Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
1928
2257
  // `network.relayAddrs` is set — that listener registers the pending reservation
@@ -1933,9 +2262,11 @@ export class CadreNode implements SAppIdLookup {
1933
2262
  // `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
1934
2263
  ...resolveAnnounceAddrs(network),
1935
2264
  // The CONTROL node composes the membership admission gate onto any
1936
- // caller-supplied gater (deny from either wins on the inbound-encrypted
1937
- // and relay-reservation hooks; all other hooks pass through). Strand
1938
- // cohort nodes keep the raw configured gater (see
2265
+ // caller-supplied gater (a deny from the caller's wins on every composed
2266
+ // hook; the membership policy refuses reservations, never connections,
2267
+ // and puts an unplaced peer's connection on the provisional deadline,
2268
+ // derived from THIS node's declared link because this node decides).
2269
+ // Strand cohort nodes keep the raw configured gater (see
1939
2270
  // strand-instance-manager.ts) — their peers are legitimately
1940
2271
  // cross-party, so cadre membership must not gate them.
1941
2272
  connectionGater: createMembershipConnectionGater(
@@ -1946,14 +2277,24 @@ export class CadreNode implements SAppIdLookup {
1946
2277
  },
1947
2278
  network?.connectionGater,
1948
2279
  ADMISSION_DECISION_TIMEOUT_MS,
1949
- relayAdmissionReserveDeadlineMs(network?.linkRoundTripMs)
2280
+ relayedRequestBudgetMs(network?.linkRoundTripMs)
1950
2281
  ),
1951
2282
  // Fail-closed per-stream authorization for the four Optimystic control-DB
1952
2283
  // protocols — the members-only layer the connection gater's stranger
1953
2284
  // carve-outs cannot express (see authorizeInboundControlStream). Control
1954
- // node only: strand cohort nodes serve cross-party peers.
2285
+ // node only: strand cohort nodes serve cross-party peers. The protocol guard
2286
+ // (installControlProtocolGuard) checks these four with this same predicate
2287
+ // first; Optimystic's own check is kept because it is a working, supported
2288
+ // upstream defence, not a second policy.
1955
2289
  authorizeInboundStream: (remotePeerId, protocol) =>
1956
- this.authorizeInboundControlStream(remotePeerId, protocol)
2290
+ this.authorizeInboundControlStream(remotePeerId, protocol),
2291
+ // Who this node SENDS control writes to (cohort and coordinator choice), where
2292
+ // `authorizeInboundStream` is who may send to it. Without it Optimystic picks cohort
2293
+ // members from the protocols a peer's peer-store record lists, which any connected
2294
+ // peer can fill. The stream gate's rule, unlogged (Optimystic asks several times per
2295
+ // peer per cohort assembly and counts refusals on its own debug line), plus a device
2296
+ // whose verified redemption is being written, which must hold the rows that admit it.
2297
+ isNetworkMember: (peerId) => this.isControlNetworkMember(peerId) || this.admittingDevices.has(peerId)
1957
2298
  };
1958
2299
 
1959
2300
  return nodeOptions;
@@ -1975,86 +2316,85 @@ export class CadreNode implements SAppIdLookup {
1975
2316
  }
1976
2317
 
1977
2318
  /**
1978
- * Decide whether an inbound CONTROL-network connection from `remotePeerId`
1979
- * should be admitted — the policy behind the connection gater wired in
1980
- * {@link createControlNode}. Deny only on a positive "unauthorized outsider
1981
- * while no stranger path is open" determination; everything ambiguous admits
1982
- * and defers to the fail-closed per-stream gates (see
2319
+ * Decide how an inbound CONTROL-network connection from `remotePeerId` is
2320
+ * admitted — the policy behind the connection gater wired in
2321
+ * {@link createControlNode}. Every connection is admitted; the answer decides
2322
+ * only whether it carries the provisional deadline (see
1983
2323
  * `membership-connection-gater.ts` for the layer's rationale and the
1984
- * stranger-open protocol allowlist).
2324
+ * stranger-open protocol allowlist). Asked once when the connection arrives
2325
+ * and again at a provisional connection's deadline.
1985
2326
  *
1986
- * Returns `'admit'` when ANY of:
2327
+ * Returns `'admit'` (no deadline) when ANY of:
1987
2328
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
1988
2329
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
1989
2330
  * configured bootstrap/relay infrastructure);
1990
- * 2. an enrollment window is open ({@link openEnrollmentWindow}, opened by
1991
- * {@link createInvite}) — the invitee dials in before it is authorized;
1992
- * 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
2331
+ * 2. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
1993
2332
  * announced it (over the strand-addr RPC) as the transport peerId of its
1994
2333
  * own strand node, so a NAT'd member's strand node can hold a
1995
2334
  * circuit-relay reservation here (see `delegate-admission.ts`).
1996
2335
  * Connection only: the per-stream gate below never honors a grant;
1997
- * 4. the authorized-member set is empty — cold start: the rows that would
2336
+ * 3. the authorized-member set is empty — cold start: the rows that would
1998
2337
  * authorize anyone arrive by replication over these very connections;
1999
- * 5. the peer IS an authorized member; or
2000
- * 6. an open invitation is OUTSTANDING — at least one unexpired,
2338
+ * 4. the peer IS an authorized member; or
2339
+ * 5. an open invitation is OUTSTANDING — at least one unexpired,
2001
2340
  * not-fully-consumed invitation this node minted or persisted (see
2002
2341
  * `StrandSolicitationService.hasOutstandingInvitation`). A formation
2003
2342
  * initiator is another party's peer by design and its token is only
2004
2343
  * checkable inside the protocol, so the gate asks the coarser question
2005
2344
  * "does this node expect a stranger at all?". REGISTERING the responder
2006
- * does not suspend stranger denial: every node registers one at
2007
- * {@link start}, and registering mints no invitation.
2345
+ * does not count: every node registers one at {@link start}, and
2346
+ * registering mints no invitation; or
2347
+ * 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
2348
+ * that is not withdrawn, unexpired, has uses left and whose issuer is
2349
+ * still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
2350
+ * redeems it is a stranger until the redemption writes its row, and its
2351
+ * proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
2352
+ * same reasoning as check 5, and the same expectation-of-a-stranger key.
2008
2353
  *
2009
2354
  * Ordering is semantically free (the checks are OR'd) but decides who pays:
2010
- * checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
2011
- * already on the deny path reaches check 6's invitation lookup.
2355
+ * checks 1-2 are in-memory, 3/4 share one control-DB read, and only a peer
2356
+ * that is otherwise headed for a provisional admission reaches check 5's and
2357
+ * 6's invitation lookups.
2012
2358
  *
2013
- * Caveats of check 6, both self-healing:
2359
+ * Caveats of check 5:
2014
2360
  * - the in-memory mint registry dies with the process, so after a restart
2015
2361
  * only invitations persisted as `FormationInvite` rows still hold the
2016
- * exemption open (re-mint otherwise — same story as the enrollment window);
2362
+ * window open (re-mint otherwise);
2017
2363
  * - a peer holding a token whose `FormationInvite` row has not replicated to
2018
- * this node yet is denied even though the formation handler would have
2019
- * accepted it, exactly like the unreplicated-membership-row case below.
2020
- *
2021
- * When every check falls through, the verdict depends on whether this node
2022
- * runs the circuit-relay server ({@link relayServer}): without one,
2023
- * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
2024
- * established by the reserving peer DIALING the relay, so a connection deny
2025
- * here kills the reservation, and that deny is NOT self-healing: an outbound
2026
- * reconcile re-dial re-establishes a data link, but no outbound dial can
2027
- * grant the remote peer a reservation, and a relay-only peer has no address
2028
- * of its own to dial back — the reservation IS its address. The gater admits
2029
- * such a connection, decides the reservation via
2030
- * {@link admitControlRelayReservation}, and drops the connection if no
2031
- * reservation is admitted in time (see `membership-connection-gater.ts` →
2032
- * "The relay-reservation seam").
2033
- *
2034
- * NOTE: check 4/5 runs a control-DB read per inbound connection
2364
+ * this node yet is admitted only provisionally, so its connection is closed
2365
+ * at the deadline unless the row has landed by then.
2366
+ *
2367
+ * When every check falls through: `'admit-provisionally'`, on every node,
2368
+ * relay server or not. The gate sees only a peer id, so it cannot tell an
2369
+ * outsider from a device whose invitation row this node has not received yet
2370
+ * (it redeems here by seating the row from its bundle, after which check 6
2371
+ * admits) or from a sibling whose membership row is still replicating (check 4
2372
+ * admits once it lands; if it has not landed by the deadline, the connection
2373
+ * is closed and either side's next reconcile dial re-forms it, since outbound
2374
+ * dials are never gated). The protocol guard refuses all of them every
2375
+ * members-only protocol meanwhile. A circuit-relay reservation admitted by
2376
+ * {@link admitControlRelayReservation} disarms the deadline (see
2377
+ * `membership-connection-gater.ts` → "The relay-reservation seam").
2378
+ *
2379
+ * NOTE: check 3/4 runs a control-DB read per inbound connection
2035
2380
  * (`listAuthorizedMembers`); connections are rare and cadres small, and this
2036
2381
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
2037
2382
  * read is safe here — unlike the per-stream gate, which must consult the
2038
2383
  * materialized {@link authorizedControlPeers} snapshot instead.
2039
- * NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
2040
- * stranger with no locally minted invitation in play, on every node now that every
2041
- * node runs the responder — a relay-enabled storage node included. If stranger
2042
- * connections to such a node ever arrive fast enough for that read to show, cache
2043
- * the answer for a few seconds.
2044
- * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
2045
- * replicated here is denied until the row converges (typically via the
2046
- * owner); either side's next outbound reconcile dial (outbound is never
2047
- * gated) re-establishes the DATA link — self-healing for data, visible as a
2048
- * transient deny. That self-healing story never covered a reservation, which
2049
- * is exactly why the relay-enabled path above exists.
2384
+ * NOTE: checks 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
2385
+ * `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
2386
+ * every node, and a provisional connection's deadline runs the whole decision once
2387
+ * more. If stranger connections ever arrive fast enough for those reads — on arrival
2388
+ * or at the deadline — to show, cache the answers for a few seconds.
2050
2389
  */
2051
2390
  private async admitInboundControlConnection(remotePeerId: string): Promise<InboundConnectionVerdict> {
2391
+ // NOTE: a node waiting to be claimed (isAwaitingClaim) lands on this empty-anchor
2392
+ // admission too, so a stranger can hold an idle, mute connection to it for as long
2393
+ // as it waits on a public address; if that ever shows up as a resource problem, put
2394
+ // unclaimed-node admissions on the provisional deadline too.
2052
2395
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
2053
2396
  return 'admit';
2054
2397
  }
2055
- if (Date.now() < this.enrollmentWindowUntil) {
2056
- return 'admit';
2057
- }
2058
2398
  if (this.delegateAdmission.has(remotePeerId)) {
2059
2399
  return 'admit';
2060
2400
  }
@@ -2069,16 +2409,15 @@ export class CadreNode implements SAppIdLookup {
2069
2409
  if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
2070
2410
  return 'admit';
2071
2411
  }
2412
+ if (await this.controlDatabase?.hasLiveCadreInvite()) {
2413
+ return 'admit';
2414
+ }
2072
2415
  } catch (error) {
2073
2416
  log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
2074
2417
  return 'admit';
2075
2418
  }
2076
- if (this.relayServer.enabled) {
2077
- log('admitInboundControlConnection: admitting %s FOR RELAY ONLY — not an authorized member and no enrollment path open; the gater drops the connection unless a reservation is admitted', remotePeerId);
2078
- return 'admit-for-relay';
2079
- }
2080
- log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
2081
- return 'deny';
2419
+ log('admitInboundControlConnection: %s is not an authorized member and no stranger window is open — admitting provisionally', remotePeerId);
2420
+ return 'admit-provisionally';
2082
2421
  }
2083
2422
 
2084
2423
  /**
@@ -2088,7 +2427,13 @@ export class CadreNode implements SAppIdLookup {
2088
2427
  * "The relay-reservation seam"); the circuit-relay server consults it per
2089
2428
  * RESERVE request, so it is never called on a node whose relay server is off.
2090
2429
  *
2091
- * Admits when ANY of:
2430
+ * Refuses everyone, ahead of every admission below, while this node is waiting to be
2431
+ * claimed ({@link isAwaitingClaim}): a storage-profile node runs the relay server by
2432
+ * default, and an unclaimed node on a public address must not become anyone's relay.
2433
+ * The empty-anchor admission in check 1 exists for a node that will be enrolled by
2434
+ * its siblings, which an unclaimed node has none of.
2435
+ *
2436
+ * Otherwise admits when ANY of:
2092
2437
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
2093
2438
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
2094
2439
  * configured bootstrap/relay infrastructure);
@@ -2108,14 +2453,18 @@ export class CadreNode implements SAppIdLookup {
2108
2453
  * that boots, reserves, and only then has its row replicate here would
2109
2454
  * otherwise keep that slot spent for the rest of the entry's TTL.
2110
2455
  *
2111
- * The connection gate's stranger carve-outs (an open enrollment window, an
2112
- * outstanding formation invitation) deliberately do NOT extend here: they
2113
- * exist so a stranger's SEED or FORMATION stream can ride a connection, and
2114
- * neither needs relay capacity. A genuine invitee that does need a relay slot
2115
- * takes one from the budget like any other unplaced peer, so an open window
2116
- * never becomes an unbounded grant of this node's forwarding capacity.
2456
+ * The connection gate's stranger carve-outs (an outstanding formation
2457
+ * invitation, a live cadre invitation) deliberately do NOT extend here: they
2458
+ * exist so a stranger's FORMATION or CADRE-INVITE stream can ride a connection,
2459
+ * and neither needs relay capacity. A genuine invitee that does need a relay
2460
+ * slot takes one from the budget like any other unplaced peer, so a live
2461
+ * invitation never becomes an unbounded grant of this node's forwarding capacity.
2117
2462
  */
2118
2463
  private async admitControlRelayReservation(remotePeerId: string): Promise<boolean> {
2464
+ if (this.isAwaitingClaim()) {
2465
+ log('admitControlRelayReservation: REFUSING %s — this node is unclaimed and relays for nobody until it is', remotePeerId);
2466
+ return false;
2467
+ }
2119
2468
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
2120
2469
  return this.admitReservationUncounted(remotePeerId);
2121
2470
  }
@@ -2138,25 +2487,13 @@ export class CadreNode implements SAppIdLookup {
2138
2487
  return true;
2139
2488
  }
2140
2489
 
2141
- /**
2142
- * Hold the control-network inbound gate open for not-yet-authorized peers
2143
- * until `untilEpochMs` (extends, never shrinks, an already-open window).
2144
- * {@link createInvite} calls this automatically; a host running an
2145
- * out-of-band enrollment flow (e.g. accepting a phone whose invite this node
2146
- * never minted) can open it explicitly before the stranger dials in.
2147
- */
2148
- openEnrollmentWindow(untilEpochMs: number): void {
2149
- this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
2150
- log('Enrollment window open until %d', this.enrollmentWindowUntil);
2151
- }
2152
-
2153
2490
  /**
2154
2491
  * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
2155
2492
  * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
2156
2493
  * at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
2157
2494
  * needs, without spending the unauthorized-reservation budget) for
2158
- * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
2159
- * authorized-membership gate has passed; public so tests can drive the
2495
+ * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder, which the
2496
+ * protocol guard admits only authorized members to; public so tests can drive the
2160
2497
  * admission policy without a full strand launch. A re-announce for the same
2161
2498
  * (announcer, strand) REPLACES the previous delegate rather than
2162
2499
  * accumulating. Never honored by {@link authorizeInboundControlStream}.
@@ -2198,59 +2535,95 @@ export class CadreNode implements SAppIdLookup {
2198
2535
  }
2199
2536
 
2200
2537
  /**
2201
- * Per-stream authorization for the four Optimystic control-DB protocols
2202
- * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`),
2203
- * wired as `authorizeInboundStream` in {@link createControlNode} — the
2204
- * fail-closed layer behind the fail-open connection gater. Control node
2205
- * ONLY: strand cohort nodes legitimately serve cross-party peers.
2538
+ * The membership rule this control node applies to a remote peer, without logging. Two
2539
+ * consumers: the per-stream gate ({@link authorizeInboundControlStream}, who may SEND to
2540
+ * this node on a members-only protocol) and Optimystic's `isNetworkMember` (who this node
2541
+ * sends control writes TO — which peers can be a control block's cohort member or
2542
+ * coordinator), which adds only a device whose verified invitation redemption is being
2543
+ * written ({@link admittingDevices}). One rule for both keeps the outbound choice as
2544
+ * permissive as the inbound acceptance: a joining node's cohort must include the inviter
2545
+ * its empty anchor or empty snapshot lets replicate to it, or it would see a self-only
2546
+ * cohort, rule an existing collection absent and found a second copy. Sereus builds the
2547
+ * control node with `bootstrapNodes: []`, so Optimystic's refused-bootstrap-peer guard
2548
+ * does not cover that.
2206
2549
  *
2207
2550
  * The STRICT SUBSET of {@link admitInboundControlConnection}: the same
2208
2551
  * "no basis to judge" admissions (shared via
2209
2552
  * {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
2210
- * (enrollment window, outstanding open invitation, delegate admission
2211
- * grant). The first two exist so a stranger can reach `/sereus/seed/1.0.0`
2212
- * and `/sereus/formation/1.0.0` — neither is gated here, and admitting a
2213
- * stranger to `repo` during an enrollment window is exactly the hole this
2214
- * gate closes. A DELEGATE-admitted connection (a member's strand node
2553
+ * (outstanding open invitation, live cadre invitation, delegate admission
2554
+ * grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
2555
+ * and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
2556
+ * stranger to `repo` while an invitation is live is exactly the hole this
2557
+ * rule closes. A DELEGATE-admitted connection (a member's strand node
2215
2558
  * holding a circuit-relay reservation, see `delegate-admission.ts`) is
2216
- * likewise exactly a case this gate must still refuse: the delegate gets
2217
- * the connection and never the control DB.
2218
- *
2219
- * Admits when ANY of:
2559
+ * likewise refused: the delegate gets the connection and never the control DB.
2560
+ *
2561
+ * Refuses everyone, ahead of every admission below, while this node is waiting to be
2562
+ * claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
2563
+ * exist so a brand-new node can take replication from the siblings that will
2564
+ * authorize it; an unclaimed node has no siblings yet, and the rows that will
2565
+ * authorize anyone arrive only after the claim, over the owner's streams that the
2566
+ * same two admissions then let in. Its cohort is itself alone meanwhile, which costs
2567
+ * nothing: it built its control database with zero connections. Configured bootstrap
2568
+ * infra is refused too: a relay opens no control-database stream.
2569
+ *
2570
+ * Otherwise admits when ANY of:
2220
2571
  * 1. a shared-baseline check admits (not running / no DB, empty anchor,
2221
- * configured bootstrap infra);
2572
+ * configured bootstrap infra — which Optimystic still classifies `foreign`,
2573
+ * since a relay serves none of the party's database protocols);
2222
2574
  * 2. the materialized authorized set is empty — cold start, before the
2223
2575
  * rows that would authorize anyone have replicated in;
2224
2576
  * 3. the peer IS in the materialized authorized set.
2225
2577
  *
2226
2578
  * Deliberately SYNCHRONOUS and pure in-memory — see
2227
2579
  * {@link authorizedControlPeers} for why a control-DB read here would
2228
- * deadlock into mutual denial. With a sync predicate the upstream deadline
2229
- * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
2230
- * default.
2580
+ * deadlock into mutual denial.
2231
2581
  *
2232
2582
  * NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
2233
2583
  * key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
2234
- * siblings deny its control-DB streams once their own snapshot is non-empty —
2235
- * owner status is a KEY, not a peer id, so being the owner does not exempt it.
2236
- * Harmless today (a real deployment persists its identity; the ephemeral-owner
2237
- * case appears only in tests, which configure the owner as bootstrap infra —
2238
- * `push-wake-e2e.integration.ts` design note 3). If an ephemeral-identity node
2239
- * ever becomes a supported deployment, this gate needs a key-based admission.
2240
- */
2241
- private authorizeInboundControlStream(remotePeerId: string, protocol: string): boolean {
2242
- if (this.admitControlPeerUnconditionally(remotePeerId)) {
2243
- return true;
2584
+ * siblings deny its control-DB streams, and leave it out of their cohorts, once
2585
+ * their own snapshot is non-empty — owner status is a KEY, not a peer id, so
2586
+ * being the owner does not exempt it. Harmless today (a real deployment persists
2587
+ * its identity; the ephemeral-owner case appears only in tests, which configure
2588
+ * the owner as bootstrap infra — `push-wake-e2e.integration.ts` design note 3).
2589
+ * If an ephemeral-identity node ever becomes a supported deployment, this rule
2590
+ * needs a key-based admission.
2591
+ */
2592
+ private isControlNetworkMember(remotePeerId: string): boolean {
2593
+ if (this.isAwaitingClaim()) {
2594
+ return false;
2244
2595
  }
2245
- if (this.authorizedControlPeers.size === 0) {
2596
+ if (this.admitControlPeerUnconditionally(remotePeerId)) {
2246
2597
  return true;
2247
2598
  }
2248
- if (this.authorizedControlPeers.has(remotePeerId)) {
2599
+ return this.authorizedControlPeers.size === 0 || this.authorizedControlPeers.has(remotePeerId);
2600
+ }
2601
+
2602
+ /**
2603
+ * Per-stream authorization for the protocol guard's `members-snapshot` class
2604
+ * (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
2605
+ * (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
2606
+ * five, and any protocol nobody classed. Also wired as Optimystic's
2607
+ * `authorizeInboundStream` in {@link createControlNode} for the four DB
2608
+ * protocols. The fail-closed layer behind the fail-open connection gater.
2609
+ * Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
2610
+ *
2611
+ * Admits exactly the peers {@link isControlNetworkMember} does, and logs why it
2612
+ * refused one. With a sync predicate the upstream deadline
2613
+ * (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its default.
2614
+ */
2615
+ private authorizeInboundControlStream(remotePeerId: string, protocol: string): boolean {
2616
+ if (this.isControlNetworkMember(remotePeerId)) {
2249
2617
  return true;
2250
2618
  }
2251
2619
  // Upstream logs its own denial line; this one carries the WHY.
2252
- log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))',
2253
- remotePeerId, protocol, this.authorizedControlPeers.size);
2620
+ if (this.isAwaitingClaim()) {
2621
+ log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is',
2622
+ remotePeerId, protocol);
2623
+ } else {
2624
+ log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))',
2625
+ remotePeerId, protocol, this.authorizedControlPeers.size);
2626
+ }
2254
2627
  return false;
2255
2628
  }
2256
2629
 
@@ -2268,13 +2641,23 @@ export class CadreNode implements SAppIdLookup {
2268
2641
  * read here would sleep its backoff holding the lock and stall every other local
2269
2642
  * writer. Nothing is lost — this refresh already keeps the previous snapshot on
2270
2643
  * failure and is re-driven by the next membership write and by the timed reconcile.
2644
+ *
2645
+ * The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
2646
+ * built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
2647
+ * has it. The invitation chain both reads may need, and the tombstone list the sync and
2648
+ * the two end-of-refresh checks ({@link hangUpRevokedControlPeers}, then
2649
+ * {@link noteOwnRemoval}) read, are each loaded at most once per refresh.
2271
2650
  */
2272
2651
  private async refreshAuthorizedControlPeers(reason: string): Promise<void> {
2273
2652
  if (!this._running || !this.controlDatabase) {
2274
2653
  return;
2275
2654
  }
2276
2655
  try {
2277
- const members = await this.listAuthorizedMembers(false);
2656
+ const loadChain = this.invitationChainSource(this.controlDatabase, false);
2657
+ const loadTombstones = this.revocationSource(this.controlDatabase, false);
2658
+ await this.syncOwnerAnchor(reason, false, loadChain, loadTombstones);
2659
+ const rows = await this.controlDatabase.queryCadrePeers(false);
2660
+ const members = this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
2278
2661
  this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
2279
2662
  log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
2280
2663
  // Remember the party's size for the NEXT launch's control-node repair yardstick
@@ -2341,11 +2724,136 @@ export class CadreNode implements SAppIdLookup {
2341
2724
  // re-arming peers whose last pass was denied under the SAME membership
2342
2725
  // snapshot instead of re-arming unconditionally.
2343
2726
  void this.controlBackfill?.scheduleConnectedPeers();
2727
+ await this.hangUpRevokedControlPeers(rows, loadTombstones);
2728
+ await this.noteOwnRemoval(rows, loadTombstones);
2344
2729
  } catch (error) {
2345
2730
  log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
2346
2731
  }
2347
2732
  }
2348
2733
 
2734
+ /**
2735
+ * Hang up this node's control connections to every peer that a `CadrePeer` tombstone names
2736
+ * and that has no live `CadrePeer` row: a machine removed from the cadre, whose streams the
2737
+ * protocol guard already refuses but whose open connection would otherwise stay up until it
2738
+ * dropped on its own. Runs at the end of every membership refresh: on the remover from the
2739
+ * removal's own membership notification, so the connection is closed before
2740
+ * {@link removeMachine} returns; on every other machine from the refresh that follows the
2741
+ * replicated tombstone.
2742
+ *
2743
+ * Denies only on evidence held, the strand enforcer's rule (`strand-revocation-enforcer.ts`):
2744
+ * a peer that merely dropped out of the authorized snapshot (an anchor read that failed, a
2745
+ * voucher whose owner is gone) keeps its connection, self is never hung up, and a peer with a
2746
+ * tombstone AND a live row under a fresh stamp was re-admitted and keeps its connection too.
2747
+ * The tombstones are the refresh's own read (`loadTombstones`, shared with
2748
+ * {@link syncOwnerAnchor}), taken only when some connected peer has no live row, so a cadre
2749
+ * with every connection accounted for costs nothing here. A failed `hangUp` is logged and
2750
+ * retried by the next refresh. Never rejects.
2751
+ */
2752
+ private async hangUpRevokedControlPeers(rows: readonly CadrePeerRow[], loadTombstones: RevocationSource): Promise<void> {
2753
+ const node = this.controlNode;
2754
+ if (!node) {
2755
+ return;
2756
+ }
2757
+ const selfPeerId = node.peerId.toString();
2758
+ const live = new Set(rows.map((row) => row.peerId));
2759
+ const rowless = new Map<string, PeerId>();
2760
+ for (const connection of node.getConnections()) {
2761
+ const remote = connection.remotePeer.toString();
2762
+ if (remote !== selfPeerId && !live.has(remote)) {
2763
+ rowless.set(remote, connection.remotePeer);
2764
+ }
2765
+ }
2766
+ if (rowless.size === 0) {
2767
+ return;
2768
+ }
2769
+ // NOTE: on a node whose anchor is empty the anchor sync skips its tombstone read, so a
2770
+ // connection to a peer with no row (a configured relay or bootstrap peer) makes this the
2771
+ // refresh's one `Revocation` read; bounded at one per refresh. If an un-enrolled node's
2772
+ // refresh ever shows up in the consult budget, skip this read while the anchor is empty.
2773
+ let revoked: Set<string>;
2774
+ try {
2775
+ revoked = new Set((await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer').map((tombstone) => tombstone.rowKey));
2776
+ } catch (error) {
2777
+ log('membership refresh: tombstone read for the hang-up of removed peers failed; next refresh retries: %o', error);
2778
+ return;
2779
+ }
2780
+ // NOTE: awaited inside the membership listener, so under the control database's write
2781
+ // lock; libp2p bounds each connection's close at its 1 s `connectionCloseTimeout`, so a
2782
+ // silent peer holds local writes for at most that. If several removed peers ever pile up
2783
+ // here, hang them up concurrently or move the call after the listener returns.
2784
+ for (const [remote, peer] of rowless) {
2785
+ if (!revoked.has(remote)) {
2786
+ continue;
2787
+ }
2788
+ try {
2789
+ await node.hangUp(peer);
2790
+ log('membership refresh: hung up %s (its CadrePeer row is tombstoned and no live row replaced it)', remote);
2791
+ } catch (error) {
2792
+ log('membership refresh: hangUp of removed peer %s failed; next refresh retries: %o', remote, error);
2793
+ }
2794
+ }
2795
+ }
2796
+
2797
+ /**
2798
+ * Learn that an owner removed this node from the cadre, at the end of every membership
2799
+ * refresh: a `CadrePeer` tombstone names this node's peer id, `rows` (the refresh's own
2800
+ * retired-stamp-filtered read) has no live row for it, and an earlier refresh of this process
2801
+ * saw one ({@link ownLiveRow}). Then it records {@link removal} and emits `cadre:removed`,
2802
+ * once. Nothing is stopped here: stopping and deleting are the embedder's, whose storage,
2803
+ * saved starts and process lifetime they are.
2804
+ *
2805
+ * The "seen live" condition keeps a machine joining again, whose fresh store can sync the old
2806
+ * tombstone before its new row, from reporting itself removed. A re-admission under a fresh
2807
+ * stamp is a live row, so a tombstone plus a live row is never a removal. The tombstones are
2808
+ * the refresh's shared read, taken only once the own row is missing. Never rejects.
2809
+ */
2810
+ private async noteOwnRemoval(rows: readonly CadrePeerRow[], loadTombstones: RevocationSource): Promise<void> {
2811
+ const selfPeerId = this.controlNode?.peerId.toString();
2812
+ if (selfPeerId === undefined || this.removal !== null) {
2813
+ return;
2814
+ }
2815
+ const own = rows.find((row) => row.peerId === selfPeerId);
2816
+ if (own) {
2817
+ this.ownLiveRow = own;
2818
+ return;
2819
+ }
2820
+ // NOTE: a machine that synced its tombstone and restarted before its embedder acted never
2821
+ // reports, since the new process never saw its row live. The embedders persist what they
2822
+ // did (a marker, a deleted party), so the window is a crash between this event and that
2823
+ // action; if it is ever hit, persist the removal here.
2824
+ const lastLive = this.ownLiveRow;
2825
+ if (lastLive === null) {
2826
+ return;
2827
+ }
2828
+ let tombstones: RevocationRow[];
2829
+ try {
2830
+ tombstones = (await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer' && tombstone.rowKey === selfPeerId);
2831
+ } catch (error) {
2832
+ log('membership refresh: tombstone read for this node\'s own removal failed; next refresh retries: %o', error);
2833
+ return;
2834
+ }
2835
+ if (tombstones.length === 0 || !this._running || !this.controlDatabase) {
2836
+ return;
2837
+ }
2838
+ const tombstone = tombstones.find((candidate) => candidate.stampId === lastLive.stampId) ?? tombstones[0];
2839
+ this.removal = { partyId: this.partyId, stampId: tombstone.stampId, signerKey: this.anchoredRevocationSigner(tombstone), at: Date.now() };
2840
+ log('membership refresh: this node was removed from cadre %s (tombstone %s, signer %s)', this.partyId, tombstone.stampId, tombstone.signerKey);
2841
+ this.emit('cadre:removed', this.removal);
2842
+ }
2843
+
2844
+ /**
2845
+ * The tombstone's signer when its signature verifies and the key is in this node's
2846
+ * trusted-owner anchor, else null. A tombstone that fails this is still a removal (every
2847
+ * other machine honours it, and this node's own row is gone), so it is logged, not refused.
2848
+ */
2849
+ private anchoredRevocationSigner(tombstone: RevocationRow): string | null {
2850
+ if (verifyRevocationSigner(this.partyId, tombstone) && this.trustedOwnerStore?.has(tombstone.signerKey)) {
2851
+ return tombstone.signerKey;
2852
+ }
2853
+ log('membership refresh: the tombstone removing this node (%s) is not signed by an owner anchored here (signer %s)', tombstone.stampId, tombstone.signerKey);
2854
+ return null;
2855
+ }
2856
+
2349
2857
  /**
2350
2858
  * Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
2351
2859
  * and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
@@ -2478,14 +2986,36 @@ export class CadreNode implements SAppIdLookup {
2478
2986
  * founded the party passes the first test and not the second (`runOwnerGenesis` in the
2479
2987
  * reference apps), and a write it signed would only be refused.
2480
2988
  */
2481
- private async enrolledOwnerSigningKey(): Promise<{ privateKeyB64: string; publicKeyB64: string } | null> {
2482
- const signingKey = this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
2989
+ private async enrolledOwnerSigningKey(): Promise<Ed25519KeyPair | null> {
2990
+ const signingKey = this.ownerSigningKeyCandidate();
2483
2991
  if (!signingKey || !this.controlDatabase) {
2484
2992
  return null;
2485
2993
  }
2486
2994
  return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
2487
2995
  }
2488
2996
 
2997
+ /**
2998
+ * Whether this node can act as an owner of its cadre: its identity key is wired as its
2999
+ * owner signing key ({@link initializeSeedBootstrap}) and enrolled in the live `OwnerKey`
3000
+ * set ({@link enrolledOwnerSigningKey}). What the owner-only flows (`createCadreInvitation`,
3001
+ * `claimNode`, `rentNode`) need; false before `start()`, and on a machine that joined as a
3002
+ * member. The reference apps' "is this device an owner" check.
3003
+ */
3004
+ async isOwner(): Promise<boolean> {
3005
+ return (await this.enrolledOwnerSigningKey()) !== null;
3006
+ }
3007
+
3008
+ /** {@link enrolledOwnerSigningKey} judged against an owner set the caller has already read. */
3009
+ private ownerSigningKeyEnrolledIn(ownerKeys: ReadonlySet<string>): Ed25519KeyPair | null {
3010
+ const signingKey = this.ownerSigningKeyCandidate();
3011
+ return signingKey !== null && ownerKeys.has(signingKey.publicKeyB64) ? signingKey : null;
3012
+ }
3013
+
3014
+ /** This machine's identity key as an owner signing key, when its seed bootstrap is wired to sign; enrollment not yet checked. */
3015
+ private ownerSigningKeyCandidate(): Ed25519KeyPair | null {
3016
+ return this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
3017
+ }
3018
+
2489
3019
  /**
2490
3020
  * Schedule the node's initial self-record publish + ongoing refresh shortly
2491
3021
  * after start (non-blocking). {@link registerSelf} is idempotent and safely
@@ -2498,7 +3028,11 @@ export class CadreNode implements SAppIdLookup {
2498
3028
  try {
2499
3029
  await this.registerSelf();
2500
3030
  } catch (error) {
2501
- // Background task — a failed publish must not crash the node.
3031
+ // Background task — a failed publish must not crash the node. A restarted machine
3032
+ // whose own CadrePeer row replicates in while this update runs fails here with
3033
+ // ConcurrentModificationError (seen on the owner's restart in every traced run of
3034
+ // cadre-invite-any-member); the next connected reconcile pass retries
3035
+ // (publishFirstSelfRecord).
2502
3036
  log('Self-registration failed: %o', error);
2503
3037
  }
2504
3038
  // Wire the ongoing refresh + control-cohort reconcile cadence, then run
@@ -2568,22 +3102,27 @@ export class CadreNode implements SAppIdLookup {
2568
3102
  }
2569
3103
 
2570
3104
  const peerId = this.controlNode.peerId.toString();
2571
- const addrs = await this.collectSelfAddrs();
2572
3105
  const existing = await this.controlDatabase.queryPeerRecord(peerId);
3106
+ // A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
3107
+ // cannot sign the insert, so it is "no owner service" here too.
3108
+ const ownerService = this.seedBootstrapService?.canAuthorize() ? this.seedBootstrapService : null;
3109
+ if (!existing && !ownerService) {
3110
+ // "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
3111
+ // retired in Revocation as absent, so a removed node lands here every heartbeat
3112
+ // and stops refreshing its own record — correct, but say so, or the message reads
3113
+ // as "never added" for a node that was removed.
3114
+ log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
3115
+ return 'skipped';
3116
+ }
3117
+ // Collected only past that skip, which a node waiting for its row replicated in takes on
3118
+ // every connected reconcile pass ({@link publishFirstSelfRecord}).
3119
+ const addrs = await this.collectSelfAddrs();
2573
3120
 
2574
- if (!existing) {
2575
- if (!this.seedBootstrapService) {
2576
- // "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
2577
- // retired in Revocation as absent, so a removed node lands here every heartbeat
2578
- // and stops refreshing its own record — correct, but say so, or the message reads
2579
- // as "never added" for a node that was removed.
2580
- log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
2581
- return 'skipped';
2582
- }
3121
+ if (!existing && ownerService) {
2583
3122
  // First-time row: requires an owner signature (the node is its own
2584
3123
  // owner). insertSelfPeerRecord throws if no owner key is present.
2585
3124
  const record = this.signSelfRecord(peerId, signingKey, addrs, null);
2586
- if (await this.seedBootstrapService.insertSelfPeerRecord(record)) {
3125
+ if (await ownerService.insertSelfPeerRecord(record)) {
2587
3126
  if (this.committedAlone()) this.pendingSelfPeerWrite = true;
2588
3127
  this.noteSelfRecordPublished();
2589
3128
  log('registerSelf: inserted own CadrePeer record (owner-signed, updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
@@ -2720,7 +3259,8 @@ export class CadreNode implements SAppIdLookup {
2720
3259
  ): PeerAddressRecord {
2721
3260
  const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
2722
3261
  return signPeerRecord(
2723
- { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt },
3262
+ this.partyId,
3263
+ { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt, profile: this.config.profile },
2724
3264
  signingKey.privateKeyB64
2725
3265
  );
2726
3266
  }
@@ -2802,6 +3342,42 @@ export class CadreNode implements SAppIdLookup {
2802
3342
  return ed25519KeyPairFromLibp2p(this.identityKey);
2803
3343
  }
2804
3344
 
3345
+ /**
3346
+ * Publish this node's own address record from the reconcile pass until its first publish of
3347
+ * this session lands. A node with no owner key — seeded (`addDrone`, `cadre enroll add`),
3348
+ * claimed (`claimNode`) or otherwise authorized after it booted — cannot insert its own
3349
+ * `CadrePeer` row; the row an owner wrote for it reaches it by replication once the two
3350
+ * connect, usually after the boot-time publish and the first-growth drain have both found
3351
+ * nothing to publish. Without this the record would wait for the heartbeat
3352
+ * ({@link startRecordRefresh}), and the cadre could not resolve the node's addresses until
3353
+ * then. The reconcile pass is where replicated membership is picked up, so each pass tries.
3354
+ * It also retries an owner's boot-time publish that failed.
3355
+ *
3356
+ * A try before the row has arrived is a `skipped` publish: one `CadrePeer` read. Not tried
3357
+ * while the node waits to be claimed (it has no row and no party yet) or once it has learned
3358
+ * it was removed (nothing to publish). A removed node that never saw its own row live in this
3359
+ * process does not learn it was removed ({@link noteOwnRemoval}), and pays that read on every
3360
+ * pass.
3361
+ *
3362
+ * Connected-only: a row written elsewhere arrives over a connection, and an owner's publish
3363
+ * committed alone is local-only and forks its own history — a restarted owner whose embedder
3364
+ * runs a pass before it reconnects would write its row on the stale side of the fork, and
3365
+ * then never see rows written while it was down (`cadre-invite-any-member` runs that pass and
3366
+ * fails every time without this condition). The boot-time publish and the first-growth drain already try at the node's lone and
3367
+ * first-connection moments; this retries on the passes after.
3368
+ */
3369
+ private async publishFirstSelfRecord(): Promise<void> {
3370
+ if (this.lastSelfRecordPublishAt !== null || this.isAwaitingClaim() || this.removal !== null
3371
+ || this.getControlConnectionCount() === 0) {
3372
+ return;
3373
+ }
3374
+ const outcome = await this.registerSelf().catch((error: unknown) => {
3375
+ log('reconcileControlCohort: first self-record publish failed (continuing): %o', error);
3376
+ return 'skipped' as const;
3377
+ });
3378
+ log('reconcileControlCohort: first self-record publish: %s', outcome);
3379
+ }
3380
+
2805
3381
  /**
2806
3382
  * Collect this node's current dialable addresses for publication, signaling
2807
3383
  * (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
@@ -2848,23 +3424,21 @@ export class CadreNode implements SAppIdLookup {
2848
3424
  };
2849
3425
  this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
2850
3426
 
2851
- // NOTE: a node AUTHORIZED AFTER IT BOOTED can wait a whole heartbeat (7.5 min)
2852
- // to publish its address. `registerSelf` no-ops while a node has no readable
2853
- // `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
2854
- // wired here are the only ones after that: `self:peer:update` needs an ADDRESS
2855
- // CHANGE, and a relay-only node's addresses stop changing the moment its
2856
- // reservation lands — inside `start()`, before this listener exists (on the
2857
- // `network.requireRelay: false` posture the reservation may instead land LATER,
2858
- // from the retry supervisor, and then this listener is what publishes the
2859
- // `/p2p-circuit` address the boot-time publish could not carry). Harmless
2860
- // where a late-authorized node's addresses keep churning (NAT/relay rotation
2861
- // fire the event anyway), and invisible while parties authorize members before
2862
- // they boot. If late enrollment becomes the normal path — an invited phone is
2863
- // exactly that — republish on the membership-change seam that already exists
2864
- // (`ControlDatabase.setMembershipChangeListener`) rather than lengthening this
2865
- // list of triggers. Measured while writing
2866
- // `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
2867
- // explicitly because neither trigger is dependable there.
3427
+ // A node AUTHORIZED AFTER IT BOOTED (seeded, claimed, invited) gets its first
3428
+ // publish from the reconcile pass ({@link publishFirstSelfRecord}), not from the
3429
+ // two triggers wired here. `registerSelf` no-ops while a node has no readable
3430
+ // `CadrePeer` row, so its boot-time publish does nothing, and neither trigger is
3431
+ // dependable after that: `self:peer:update` needs an ADDRESS CHANGE, and a
3432
+ // relay-only node's addresses stop changing the moment its reservation lands —
3433
+ // inside `start()`, before this listener exists (on the `network.requireRelay:
3434
+ // false` posture the reservation may instead land LATER, from the retry
3435
+ // supervisor, and then this listener is what publishes the `/p2p-circuit` address
3436
+ // the boot-time publish could not carry); the heartbeat is 7.5 min away. Nor is
3437
+ // the membership-change seam (`ControlDatabase.setMembershipChangeListener`): it
3438
+ // fires on LOCAL commits only, and the row a late-authorized node waits for is
3439
+ // written elsewhere. Measured while writing `relay-only-control-addr.integration.ts`
3440
+ // case 4, which drives `registerSelf()` explicitly because neither trigger is
3441
+ // dependable there.
2868
3442
  this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
2869
3443
  (this.recordRefreshTimer as { unref?: () => void } | null)?.unref?.();
2870
3444
 
@@ -2976,7 +3550,7 @@ export class CadreNode implements SAppIdLookup {
2976
3550
  }
2977
3551
 
2978
3552
  // Self-signature over (peerId, addrs, updatedAt).
2979
- if (!verifyPeerRecordSignature(record)) {
3553
+ if (!verifyPeerRecordSignature(this.partyId, record)) {
2980
3554
  // Field detail distinguishes "holding the owner-vouch revision (Sig null,
2981
3555
  // no addrs — a not-yet-self-published row)" from a genuinely corrupt or
2982
3556
  // mixed-revision row. Sig/addrs are public replicated data.
@@ -3035,8 +3609,9 @@ export class CadreNode implements SAppIdLookup {
3035
3609
  * ({@link bootstrapDialAddrs}).
3036
3610
  *
3037
3611
  * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
3038
- * `peerId`, so it does not belong in this peer's candidate list (libp2p's own
3039
- * dial queue filters the same shape out one layer lower).
3612
+ * `peerId`, so it does not belong in this peer's candidate list (`dialPeerAddrs`
3613
+ * binds every address to the peer it is given and drops the same shape one
3614
+ * layer lower; here it is dropped before it can reach the address book).
3040
3615
  */
3041
3616
  private normalizeDialAddrs(addrs: Multiaddr[], peerId: string): Multiaddr[] {
3042
3617
  // Keyed by the NORMALIZED string, so two entries differing only by the suffix
@@ -3169,6 +3744,10 @@ export class CadreNode implements SAppIdLookup {
3169
3744
  if (!this._running || !this.controlNode || !this.controlDatabase) {
3170
3745
  return { dialed: [] };
3171
3746
  }
3747
+ await this.publishFirstSelfRecord();
3748
+ if (!this._running || !this.controlNode || !this.controlDatabase) {
3749
+ return { dialed: [] };
3750
+ }
3172
3751
  // Refresh relay delegate grants BEFORE the sibling enumeration below: a
3173
3752
  // solo cadre with a party relay has no siblings to dial but must still keep
3174
3753
  // its running strands' grants alive (a dropped relay connection re-dials
@@ -3464,8 +4043,7 @@ export class CadreNode implements SAppIdLookup {
3464
4043
  * addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
3465
4044
  * ({@link dialControlSibling}) and cold-start bootstrap peer
3466
4045
  * ({@link dialBootstrapPeer}) dials, and — handed to every
3467
- * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
3468
- * `dialInvite`.
4046
+ * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials.
3469
4047
  *
3470
4048
  * See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
3471
4049
  * bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
@@ -3540,6 +4118,7 @@ export class CadreNode implements SAppIdLookup {
3540
4118
  log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
3541
4119
  await dialPeerAddrs(
3542
4120
  controlNode,
4121
+ sibling.peerId,
3543
4122
  addrs,
3544
4123
  this.controlDialBudget(),
3545
4124
  `reconcileControlCohort dial of sibling ${sibling.peerId}`
@@ -3575,9 +4154,9 @@ export class CadreNode implements SAppIdLookup {
3575
4154
  *
3576
4155
  * The list may name transports this node cannot dial (a lent node reports TCP
3577
4156
  * and `/ws` addresses to a phone that dials WebSockets only). That needs no
3578
- * filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
3579
- * rejects an address no transport can dial before touching the network, so
3580
- * such an address costs a log line.
4157
+ * filtering here: libp2p's transport manager rejects an address no transport
4158
+ * can dial before touching the network (`TransportUnavailableError`), so such
4159
+ * an address costs a log line.
3581
4160
  *
3582
4161
  * The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
3583
4162
  * addresses one after another under a single deadline, and sorts loopback
@@ -3806,6 +4385,7 @@ export class CadreNode implements SAppIdLookup {
3806
4385
  log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
3807
4386
  await dialPeerAddrs(
3808
4387
  controlNode,
4388
+ peerId,
3809
4389
  parsed,
3810
4390
  this.controlDialBudget(),
3811
4391
  `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`
@@ -3901,7 +4481,7 @@ export class CadreNode implements SAppIdLookup {
3901
4481
  return;
3902
4482
  }
3903
4483
  this.pendingRevocations.set(revocation.stampId, revocation);
3904
- log('%s delete of %s committed while alone (0 control connections); Revocation tombstone ' +
4484
+ log('%s tombstone for %s committed while alone (0 control connections); Revocation tombstone ' +
3905
4485
  '(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
3906
4486
  }
3907
4487
 
@@ -3976,7 +4556,7 @@ export class CadreNode implements SAppIdLookup {
3976
4556
  // 4. In-session pending owner authorize writes.
3977
4557
  await this.drainPendingPeerWrites();
3978
4558
 
3979
- // 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
4559
+ // 5. Pending joins this process wrote while alone. Their tombstones went out in step 1.
3980
4560
  await this.pendingJoinRunner?.reissueWritesMadeAlone();
3981
4561
  }
3982
4562
 
@@ -4036,7 +4616,10 @@ export class CadreNode implements SAppIdLookup {
4036
4616
  */
4037
4617
  private async drainPendingRevocations(): Promise<void> {
4038
4618
  const sweep = !this.reissuedHeldRevocations;
4039
- if (!sweep && this.pendingRevocations.size === 0) {
4619
+ // A removed machine's owner key went with its row (`removeMachine`), so the schema's
4620
+ // live-owner clause refuses every re-issue it could sign; reconstructAuthoredMembership
4621
+ // and drainPendingPeerWrites stop on the same test.
4622
+ if (this.removal !== null || (!sweep && this.pendingRevocations.size === 0)) {
4040
4623
  return;
4041
4624
  }
4042
4625
  if (!this._running || !this.controlNode || !this.controlDatabase) {
@@ -4062,6 +4645,10 @@ export class CadreNode implements SAppIdLookup {
4062
4645
  this.pendingRevocations.delete(row.stampId);
4063
4646
  }
4064
4647
  } catch (error) {
4648
+ // NOTE: an owner whose key alone was removed (`removeOwner`, its machine still a member,
4649
+ // so not a removal this node reports) keeps running these drains, and every attempt
4650
+ // fails the schema's live-owner clause and lands here on each growth edge. Noise, not
4651
+ // harm; if it shows up, gate the drains on this node's key being a live owner.
4065
4652
  log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
4066
4653
  }
4067
4654
  }
@@ -4084,7 +4671,7 @@ export class CadreNode implements SAppIdLookup {
4084
4671
  * `CadrePeer` trace, but its tombstone is re-issuable).
4085
4672
  */
4086
4673
  private async reconstructAuthoredMembership(): Promise<void> {
4087
- if (!this.controlDatabase || !this.controlNode) {
4674
+ if (!this.controlDatabase || !this.controlNode || this.removal !== null) {
4088
4675
  return;
4089
4676
  }
4090
4677
  if (!this.seedBootstrapService?.canAuthorize()) {
@@ -4146,7 +4733,7 @@ export class CadreNode implements SAppIdLookup {
4146
4733
  * here.
4147
4734
  */
4148
4735
  private async drainPendingPeerWrites(): Promise<void> {
4149
- if (this.pendingPeerWrites.size === 0) {
4736
+ if (this.pendingPeerWrites.size === 0 || this.removal !== null) {
4150
4737
  return;
4151
4738
  }
4152
4739
  if (!this.seedBootstrapService?.canAuthorize()) {
@@ -4299,6 +4886,7 @@ export class CadreNode implements SAppIdLookup {
4299
4886
  // Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
4300
4887
  const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
4301
4888
  const record = signDeviceTokenRecord(
4889
+ this.partyId,
4302
4890
  { peerId, platform, token, updatedAt },
4303
4891
  signingKey.privateKeyB64
4304
4892
  );
@@ -4395,7 +4983,7 @@ export class CadreNode implements SAppIdLookup {
4395
4983
 
4396
4984
  // Self-signature over (peerId, platform, token, updatedAt), verified against the
4397
4985
  // CadrePeer.PublicKey bound to this peerId.
4398
- if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
4986
+ if (!verifyDeviceTokenSignature(this.partyId, record, peerRecord.publicKey)) {
4399
4987
  log('resolveDeviceToken: signature verification failed for %s', peerId);
4400
4988
  return null;
4401
4989
  }
@@ -4656,6 +5244,12 @@ export class CadreNode implements SAppIdLookup {
4656
5244
  this.strandAddrService = null;
4657
5245
  }
4658
5246
 
5247
+ // Unregister the cadre invitation redemption handler, for the same reason.
5248
+ if (this.cadreInviteHandler && this.controlNode) {
5249
+ await this.cadreInviteHandler.unregister(this.controlNode);
5250
+ }
5251
+ this.cadreInviteHandler = null;
5252
+
4659
5253
  // Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
4660
5254
  if (this.pushFanoutService) {
4661
5255
  await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
@@ -5947,6 +6541,8 @@ export class CadreNode implements SAppIdLookup {
5947
6541
  // comes up `'syncing'` announces the moment its database is published.
5948
6542
  firstSync: this.config.strandFirstSync,
5949
6543
  onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
6544
+ onCommitDurability: (commit) => this.emit('strand:commit-durability', commit),
6545
+ onCommitHeld: (held) => this.emit('strand:commit-held', held),
5950
6546
  // Re-announce the delegate to ONE relay before the strand node's reservation
5951
6547
  // supervisor re-drives it (see announceDelegateToRelay). Retained with the
5952
6548
  // launch config, so a hibernation wake's rebuilt supervisors get it too.
@@ -7120,6 +7716,15 @@ export class CadreNode implements SAppIdLookup {
7120
7716
  *
7121
7717
  * Passing an empty list stops the supervisor and resets the posture to `none`
7122
7718
  * without dialing or waiting.
7719
+ *
7720
+ * NOTE: on a node with no `network.relayAddrs` (a browser tab with a hand-written
7721
+ * bare `/p2p-circuit`), libp2p's relay discovery stays on, because it was the only
7722
+ * thing filling that slot before this call. Such a tab connected to some other
7723
+ * relay-capable peer it identifies (a party member running the relay server) can
7724
+ * lose its one slot to that peer before this supervisor's request runs, the theft
7725
+ * `nodeTransportsOption` prevents on configured nodes (`relay-reservation.ts`). If a
7726
+ * tab is ever seen reserved through a relay it was not asked to use, build that
7727
+ * node through `withoutRelayDiscovery` too, or have the tab configure `relayAddrs`.
7123
7728
  */
7124
7729
  async reserveRelays(
7125
7730
  addrs: string[],
@@ -7228,22 +7833,35 @@ export class CadreNode implements SAppIdLookup {
7228
7833
  if (!this.controlNode || !this.controlDatabase) {
7229
7834
  throw new Error('CadreNode must be started before initializing seed bootstrap');
7230
7835
  }
7836
+ // A hosted node waiting to be claimed never holds an owner key: the claim is its
7837
+ // one trust source, and genesis-anchoring a key here would mark it claimed by
7838
+ // itself. Refused here rather than in start() because this call comes after it.
7839
+ if (this.config.claim) {
7840
+ throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
7841
+ }
7231
7842
 
7232
7843
  // A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
7233
7844
  // key an authority of its own party (founder genesis / self-signing owner)
7234
7845
  // — anchor it in the node-local store. The in-memory set updates
7235
7846
  // synchronously (see TrustedOwnerStore.trust); only the file persist is
7236
7847
  // fire-and-forget, logged on failure.
7237
- // NOTE: non-founder members that also wire seed-bootstrap with their own
7238
- // derived key (e.g. the phone joiner path in runOwnerGenesis, which needs
7239
- // it to self-publish its CadrePeer row) self-anchor a key that is not a
7240
- // party authority. Harmless while such a node never mints an invite: the
7241
- // store does not replicate, so a node trusting itself grants nothing to
7242
- // others. But `createInvite` now hands out the anchor's contents as the
7243
- // invitee's pins, so the moment a non-founder member mints an invite it
7244
- // exports its own non-authority key as a cadre owner key. If that becomes
7245
- // reachable (today only cadre-cli/cadre-host owners mint invites), gate this
7246
- // self-anchor on the actual OwnerKey genesis insert instead.
7848
+ // NOTE: wiring a key that is not a live owner here self-anchors a key that is
7849
+ // no party authority, and the anchor sync (`syncOwnerAnchor`) does not correct
7850
+ // it: a `genesis` entry is base, kept whether or not a live `OwnerKey` row
7851
+ // vouches it. A machine that joined by invitation reaches this only through
7852
+ // `wireOwnKeyIfOwner`, which checks the row first, so a joined non-owner never
7853
+ // self-anchors. Three callers still wire unconditionally on every start, so a
7854
+ // node of theirs started for a party founded elsewhere does: the phone kit's
7855
+ // `runOwnerGenesis` (cadre-rn; only for a party its saved start does not record
7856
+ // as joined, whose starts call `wireOwnKeyIfOwner` instead), the web reference
7857
+ // app's start (`cadre-web.ts`) and `cadre start --owner`. Contained while the store does
7858
+ // not replicate and such a node cannot mint: `createCadreInvitation` hands out
7859
+ // the anchor as the device's pins, but the `CadreInvite` insert needs a live
7860
+ // owner as issuer. If anything hands out the anchor without that check, gate
7861
+ // this self-anchor on a live `OwnerKey` row instead. A non-owner needs no
7862
+ // owner key to publish its own address record: once an owner or a redemption
7863
+ // has written its `CadrePeer` row, `registerSelf` refreshes it with the
7864
+ // identity key alone.
7247
7865
  if (this.trustedOwnerStore) {
7248
7866
  void this.trustedOwnerStore
7249
7867
  .trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
@@ -7253,9 +7871,8 @@ export class CadreNode implements SAppIdLookup {
7253
7871
  await this.installSeedBootstrapService(new SeedBootstrapService({
7254
7872
  partyId: this.config.controlNetwork.partyId,
7255
7873
  ownerPrivateKey,
7256
- inviteAddressResolver: () => this.resolveInviteAddresses(),
7257
7874
  ...this.seedServiceBudgets(),
7258
- trustPolicy: this.config.seedTrustPolicy,
7875
+ trustPolicy: this.inboundSeedTrustPolicy(),
7259
7876
  // Seed trust anchors on the node-local store (seeded just above with this
7260
7877
  // node's own genesis key), never on the replicated OwnerKey table.
7261
7878
  ...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
@@ -7341,6 +7958,11 @@ export class CadreNode implements SAppIdLookup {
7341
7958
  * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
7342
7959
  * never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
7343
7960
  * revoked peer is not addressable either.
7961
+ *
7962
+ * NOTE: answers the whole `CadrePeerRow`, past the two fields declared; the integration
7963
+ * harness (`owner-cli-node.ts`, `OwnerCliMemberRow.sig`) reads `sig` off it through the admin
7964
+ * channel to tell a row another member seated from one the subject published itself. Narrow
7965
+ * this to the declared fields only together with that reader.
7344
7966
  */
7345
7967
  async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
7346
7968
  if (!this.controlDatabase) {
@@ -7373,14 +7995,21 @@ export class CadreNode implements SAppIdLookup {
7373
7995
  * 1. it is not this node itself — a node publishes its own `CadrePeer` address
7374
7996
  * row so its dialable address rides in seeds, but "self" is not a peer this
7375
7997
  * node authorized;
7376
- * 2. its `CadrePeer` row carries a complete voucher (`StampId`, `VouchOwner`,
7377
- * `VouchSig` all non-null);
7998
+ * 2. its `CadrePeer` row carries a complete proof: EITHER an owner voucher
7999
+ * (`StampId`, `VouchOwner`, `VouchSig` all non-null) OR an invitation admission
8000
+ * (`VouchSig` null, `VouchUsage` naming a `CadreInviteUsage` row);
7378
8001
  * 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
7379
- * ({@link getTrustedOwnerStore}) — never the replicated `OwnerKey` table,
7380
- * which any stranger can genesis-pollute; and
7381
- * 4. `VouchSig` verifies as that owner's signature over the row's voucher
7382
- * digest ({@link verifyCadrePeerVoucher}), so the anchored owner really
7383
- * vouched THIS peer id under THIS row's nonce; and
8002
+ * ({@link getTrustedOwnerStore}), which follows the replicated `OwnerKey` table
8003
+ * only along chains that verify from this node's out-of-band keys
8004
+ * ({@link syncOwnerAnchor}) — never the table itself, which any stranger can
8005
+ * genesis-pollute; and
8006
+ * 4. the proof verifies: for a voucher, `VouchSig` is that owner's signature over
8007
+ * the row's voucher digest ({@link verifyCadrePeerVoucher}), so the anchored
8008
+ * owner really vouched THIS peer id under THIS row's nonce; for an admission,
8009
+ * the chain row → usage → invitation → anchored issuer verifies link by link
8010
+ * ({@link verifyInvitationAdmission} — the issuer is `VouchOwner`, the invitation's
8011
+ * own owner signature, the holder's and the device's signatures, and the stored
8012
+ * peer key really is the key behind the peer id); and
7384
8013
  * 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
7385
8014
  * upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
7386
8015
  * before ANY reader (this predicate included) sees them, so a row resurrected
@@ -7389,18 +8018,26 @@ export class CadreNode implements SAppIdLookup {
7389
8018
  * local rows) is still inert to every reader that has the tombstone.
7390
8019
  *
7391
8020
  * Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
7392
- * not-yet-enrolled node authorizes no one), a null/partial voucher, an
7393
- * unanchored `VouchOwner`, or a bad signature all yield "not authorized" —
7394
- * having an address row is NOT membership. The control-network wake and
7395
- * strand-address gates consult this set, NOT the addressable one.
8021
+ * not-yet-enrolled node authorizes no one), a null/partial proof, an
8022
+ * unanchored `VouchOwner`, a bad signature, or a usage or invitation row this node
8023
+ * does not hold all yield "not authorized" — having an address row is NOT
8024
+ * membership. The protocol guard admits wake and strand-address requests by this set,
8025
+ * NOT the addressable one.
8026
+ *
8027
+ * The usage and invitation tables are read only when at least one row needs them,
8028
+ * so a cadre with no invitation-admitted member stays at one read per call.
7396
8029
  *
7397
8030
  * NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
7398
8031
  * in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
7399
8032
  * legit member goes un-authorized on readers that only pin the new key. Full
7400
8033
  * rotation handling (re-vouch on rotate) is the
7401
- * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
8034
+ * `flip-strand-membership-rotation-known-gap` work, not this predicate's. The
8035
+ * `OwnerKey` table differs: an owner the rotated-out (or removed) key vouched keeps
8036
+ * its place in every anchor that had derived it ({@link syncOwnerAnchor}: a derived
8037
+ * entry stays while its own row is live), and is only out of reach for a machine that
8038
+ * never held it until a remaining owner re-adds it or an invitation pins it.
7402
8039
  *
7403
- * @param retry - Whether the underlying membership read may retry a transient cluster
8040
+ * @param retry - Whether the underlying membership reads may retry a transient cluster
7404
8041
  * failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
7405
8042
  * as the control database's membership listener with that database's write lock held;
7406
8043
  * see its comment.
@@ -7409,10 +8046,36 @@ export class CadreNode implements SAppIdLookup {
7409
8046
  if (!this.controlDatabase) {
7410
8047
  throw new Error('CadreNode must be started before listing members');
7411
8048
  }
8049
+ return await this.collectAuthorizedMembers(this.controlDatabase, retry, this.invitationChainSource(this.controlDatabase, retry));
8050
+ }
8051
+
8052
+ /**
8053
+ * The body of {@link listAuthorizedMembers}, with the invitation chain supplied as a
8054
+ * source so the membership refresh can share one load with {@link syncOwnerAnchor}.
8055
+ */
8056
+ private async collectAuthorizedMembers(
8057
+ controlDatabase: ControlDatabase,
8058
+ retry: boolean,
8059
+ loadChain: InvitationChainSource,
8060
+ ): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
8061
+ const rows = await controlDatabase.queryCadrePeers(retry);
8062
+ return this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
8063
+ }
8064
+
8065
+ /** The invitation chain, loaded only when some row in `rows` is invitation-admitted. */
8066
+ private async chainFor(rows: readonly CadrePeerRow[], loadChain: InvitationChainSource): Promise<InvitationChain | null> {
8067
+ return rows.some(isInvitationAdmitted) ? await loadChain() : null;
8068
+ }
8069
+
8070
+ /**
8071
+ * The authorized members among `rows`, already read: checks 1 to 4 of
8072
+ * {@link listAuthorizedMembers}' predicate (check 5 is `queryCadrePeers`'s), so the membership
8073
+ * refresh can judge the rows it has to read anyway for the hang-up of removed peers.
8074
+ */
8075
+ private authorizedAmong(rows: readonly CadrePeerRow[], chain: InvitationChain | null): Array<{ peerId: string; multiaddr: string | null }> {
7412
8076
  const selfPeerId = this.peerId?.toString();
7413
- const rows = await this.controlDatabase.queryCadrePeers(retry);
7414
8077
  const authorized = rows
7415
- .filter(row => row.peerId !== selfPeerId && this.hasAnchoredVoucher(row))
8078
+ .filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
7416
8079
  .map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
7417
8080
  // A node whose anchor was never seeded (no invite pin, no operator pin, not a
7418
8081
  // founder) refuses every wake and strand-addr request, which from the outside
@@ -7426,9 +8089,168 @@ export class CadreNode implements SAppIdLookup {
7426
8089
 
7427
8090
  /**
7428
8091
  * Checks 2–4 of the authorized-membership predicate (see
7429
- * {@link listAuthorizedMembers}) for one `CadrePeer` row: complete voucher,
7430
- * `VouchOwner` in the node-local anchor, signature valid over the row's
7431
- * (PeerId, StampId) voucher digest.
8092
+ * {@link listAuthorizedMembers}) for one `CadrePeer` row, by the kind of proof it
8093
+ * carries: an owner voucher ({@link hasAnchoredVoucher}) or an invitation admission
8094
+ * ({@link verifyInvitationAdmission} over the usage and invitation rows in `chain`,
8095
+ * loaded only when some row needs them). A row with a `VouchSig` is judged as a
8096
+ * voucher even if it also names a usage — the signature is the stronger proof and the
8097
+ * one an owner re-vouch leaves behind.
8098
+ */
8099
+ private hasAnchoredProof(row: CadrePeerVoucherFields, chain: InvitationChain | null): boolean {
8100
+ if (!isInvitationAdmitted(row)) {
8101
+ return this.hasAnchoredVoucher(row);
8102
+ }
8103
+ const usage = chain?.usages.get(row.vouchUsage!);
8104
+ const invite = usage === undefined ? undefined : chain?.invites.get(usage.inviteKey);
8105
+ if (usage === undefined || invite === undefined) {
8106
+ return false;
8107
+ }
8108
+ return verifyInvitationAdmission(this.partyId, row, usage, invite, key => this.trustedOwnerStore?.has(key) ?? false);
8109
+ }
8110
+
8111
+ /**
8112
+ * Recompute the node-local trusted-owner anchor from the replicated `OwnerKey` table along
8113
+ * verifiable chains (`owner-anchor-sync.ts` → {@link deriveOwnerAnchor}), as the first step
8114
+ * of every membership refresh ({@link refreshAuthorizedControlPeers}) so the snapshot built
8115
+ * in the same pass judges `CadrePeer` rows against the updated anchor. A key whose live
8116
+ * row's stored proof verifies against a key already anchored enters under `chain` and
8117
+ * stays while its row is live; a key an anchored owner's verifiable tombstone names
8118
+ * leaves, base pins included — which is how a config pin of a removed key, re-anchored at
8119
+ * every start ({@link initializeTrustedOwnerStore}), is removed again. The derivation
8120
+ * refuses removals that would empty the anchor; they are logged here.
8121
+ *
8122
+ * With an EMPTY anchor the sync does nothing: there is no key to derive from, and a node
8123
+ * waiting to be claimed ({@link isAwaitingClaim}: claim secret set, anchor empty) must
8124
+ * stay empty until the claim anchors its claimant — the replicated table must never seat
8125
+ * the first key. The early return below is the seam that rests on.
8126
+ *
8127
+ * Any read failure skips the whole pass and leaves the anchor as it was; the refresh then
8128
+ * builds its snapshot against that anchor, which is also what its previous snapshot was
8129
+ * judged against. Caught here rather than failing the refresh, because the membership read
8130
+ * tolerates an unreachable `Revocation` block on an isolated node
8131
+ * (`ControlDatabase.readRevokedStampRows`) and `queryRevocations` does not: failing the
8132
+ * whole refresh on that would freeze such a node's gate snapshot.
8133
+ * NOTE: on such a node the sync is skipped at every refresh until the block is held; if an
8134
+ * isolated node ever has to derive owner additions while cut off, give `queryRevocations`
8135
+ * the same cohort-unreachable fallback.
8136
+ *
8137
+ * NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
8138
+ * tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
8139
+ * read the retired set again, and which the hang-up of removed peers shares through
8140
+ * `loadTombstones`), plus the invitation chain when some owner row is
8141
+ * invitation-admitted — shared with the membership read through `loadChain`; the memo
8142
+ * suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
8143
+ * marker is filed that one read is a cohort consult per refresh, which
8144
+ * `control-founding-consult-budget.spec.ts` pins.
8145
+ *
8146
+ * @param retry - forwarded to every read; `false` on the refresh path, for the reason on
8147
+ * {@link refreshAuthorizedControlPeers}.
8148
+ * @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
8149
+ */
8150
+ private async syncOwnerAnchor(reason: string, retry: boolean, loadChain: InvitationChainSource, loadTombstones: RevocationSource): Promise<void> {
8151
+ const store = this.trustedOwnerStore;
8152
+ const controlDatabase = this.controlDatabase;
8153
+ if (!store || !controlDatabase) {
8154
+ return;
8155
+ }
8156
+ const before = store.sources();
8157
+ if (before.size === 0) {
8158
+ return;
8159
+ }
8160
+ let target: ReadonlySet<string>;
8161
+ try {
8162
+ const tombstones = await loadTombstones();
8163
+ const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
8164
+ const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
8165
+ const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
8166
+ const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
8167
+ if (derivation.refusedRemovals.length > 0) {
8168
+ log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
8169
+ }
8170
+ target = derivation.target;
8171
+ } catch (error) {
8172
+ log('syncOwnerAnchor(%s): read failed — anchor left as it was: %o', reason, error);
8173
+ return;
8174
+ }
8175
+ await this.applyOwnerAnchor(store, before, target, reason);
8176
+ }
8177
+
8178
+ /**
8179
+ * Apply one pass's result: `chain` entries for the keys it newly derived, removal of the keys
8180
+ * it started with that are outside `target` — base keys a verifiable tombstone named, and
8181
+ * `chain` entries whose row is gone.
8182
+ *
8183
+ * Diffed against `before`, the snapshot the derivation was computed from, NOT the live store:
8184
+ * the anchor is written out of band while the pass's reads are in flight (an invitation pin,
8185
+ * the genesis anchor, a claim, a seed), and such a key is outside `target` only because the
8186
+ * pass never saw it — judging the live store would remove it. Likewise a key removed
8187
+ * meanwhile (a claim rollback) must not come back as `chain`. Both are judged by the next
8188
+ * pass. Each change lands in memory synchronously (`TrustedOwnerStore.trust` / `remove`); a
8189
+ * failed PERSIST is logged and does not stop the pass or fail the refresh, as
8190
+ * `SeedBootstrapService.anchorAcceptedSigner` treats the same failure.
8191
+ */
8192
+ private async applyOwnerAnchor(
8193
+ store: TrustedOwnerStore,
8194
+ before: ReadonlyMap<string, TrustSource>,
8195
+ target: ReadonlySet<string>,
8196
+ reason: string,
8197
+ ): Promise<void> {
8198
+ for (const key of target) {
8199
+ if (!before.has(key) && !store.has(key)) {
8200
+ log('syncOwnerAnchor(%s): owner %s derived from the OwnerKey table', reason, key);
8201
+ await store.trust(key, 'chain').catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
8202
+ }
8203
+ }
8204
+ for (const key of before.keys()) {
8205
+ if (!target.has(key) && store.has(key)) {
8206
+ log('syncOwnerAnchor(%s): owner %s removed from the anchor', reason, key);
8207
+ await store.remove(key).catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
8208
+ }
8209
+ }
8210
+ }
8211
+
8212
+ /**
8213
+ * A per-refresh {@link InvitationChainSource}: the first call loads the chain through
8214
+ * {@link loadInvitationChain}, every later call shares that load.
8215
+ */
8216
+ private invitationChainSource(controlDatabase: ControlDatabase, retry: boolean): InvitationChainSource {
8217
+ let loading: Promise<InvitationChain> | null = null;
8218
+ return () => (loading ??= this.loadInvitationChain(controlDatabase, retry));
8219
+ }
8220
+
8221
+ /** A per-refresh {@link RevocationSource}: one `queryRevocations` read, shared by every caller in the pass. */
8222
+ private revocationSource(controlDatabase: ControlDatabase, retry: boolean): RevocationSource {
8223
+ let loading: Promise<RevocationRow[]> | null = null;
8224
+ return () => (loading ??= controlDatabase.queryRevocations(retry));
8225
+ }
8226
+
8227
+ /**
8228
+ * The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
8229
+ * resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
8230
+ * for {@link listAuthorizedMembers}' reason.
8231
+ *
8232
+ * NOTE: once invitations are the only admission route (ticket
8233
+ * `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
8234
+ * re-vouched pays these two whole-table reads and three ed25519 verifies per row on every
8235
+ * membership refresh. Both tables are append-only and a verified chain never changes (expiry,
8236
+ * use count and withdrawal are not re-checked), so if the gate's admission deadline ever
8237
+ * shows this, memoise verified (row stamp → usage stamp) pairs per node and skip the reads
8238
+ * when every admitted row is already memoised.
8239
+ */
8240
+ private async loadInvitationChain(controlDatabase: ControlDatabase, retry: boolean): Promise<InvitationChain> {
8241
+ const [usages, invites] = await Promise.all([
8242
+ controlDatabase.queryCadreInviteUsages(retry),
8243
+ controlDatabase.queryCadreInvites(retry),
8244
+ ]);
8245
+ return {
8246
+ usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
8247
+ invites: new Map(invites.map(invite => [invite.key, invite])),
8248
+ };
8249
+ }
8250
+
8251
+ /**
8252
+ * The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
8253
+ * the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
7432
8254
  *
7433
8255
  * NOTE: verifies the ed25519 signature on every call (no memo of already-
7434
8256
  * verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
@@ -7443,13 +8265,13 @@ export class CadreNode implements SAppIdLookup {
7443
8265
  if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
7444
8266
  return false;
7445
8267
  }
7446
- return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
8268
+ return verifyCadrePeerVoucher(this.partyId, row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
7447
8269
  }
7448
8270
 
7449
8271
  /**
7450
8272
  * Probe whether a given peer is an AUTHORIZED party member (see
7451
- * {@link listAuthorizedMembers}) — the gate the control-network wake and
7452
- * strand-address responders consult, NOT {@link isMember} (the addressable
8273
+ * {@link listAuthorizedMembers}) — the check the protocol guard runs ahead of the
8274
+ * control-network wake and strand-address handlers, NOT {@link isMember} (the addressable
7453
8275
  * surface). Deliberately scans the full membership (one `CadrePeer` query)
7454
8276
  * rather than adding a single-row read path: cadres are small, and one code
7455
8277
  * path keeps the predicate impossible to drift from the list.
@@ -7466,14 +8288,16 @@ export class CadreNode implements SAppIdLookup {
7466
8288
  * record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
7467
8289
  * is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
7468
8290
  * the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
7469
- * gates the request on cadre membership and only resumes a strand it already
7470
- * participates in; it acks once it has decided, before the strand is up. The
7471
- * dial deadlines derive from this node's `network.linkRoundTripMs`.
8291
+ * only resumes a strand it already participates in; it acks once it has decided,
8292
+ * before the strand is up. The dial deadlines derive from this node's
8293
+ * `network.linkRoundTripMs`.
7472
8294
  *
7473
8295
  * @param targetPeerId - The hibernating cadre peer to wake.
7474
8296
  * @param strandId - The strand the caller knows has pending activity.
7475
8297
  * @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
7476
- * @throws if the node is not started or the target has no dialable address.
8298
+ * @throws if the node is not started, the target has no dialable address, or no
8299
+ * attempt got an ack — including when the receiver does not count this node as an
8300
+ * authorized member, since its protocol guard resets the stream without a reply.
7477
8301
  */
7478
8302
  async pushWake(targetPeerId: string, strandId: string, reason?: string): Promise<WakeAck> {
7479
8303
  if (!this.controlNode) {
@@ -7490,7 +8314,8 @@ export class CadreNode implements SAppIdLookup {
7490
8314
  /**
7491
8315
  * Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
7492
8316
  * This is for drone nodes that need to receive seeds without being an owner.
7493
- * Does not require an owner key.
8317
+ * Does not require an owner key. Idempotent; a node started with `config.claim`
8318
+ * calls it itself inside {@link start}, so an embedder's own call is a no-op.
7494
8319
  */
7495
8320
  async enableSeedListener(): Promise<void> {
7496
8321
  if (!this.controlNode || !this.controlDatabase) {
@@ -7506,9 +8331,8 @@ export class CadreNode implements SAppIdLookup {
7506
8331
  await this.installSeedBootstrapService(new SeedBootstrapService({
7507
8332
  partyId: this.config.controlNetwork.partyId,
7508
8333
  // No owner key - this node only receives seeds
7509
- inviteAddressResolver: () => this.resolveInviteAddresses(),
7510
8334
  ...this.seedServiceBudgets(),
7511
- trustPolicy: this.config.seedTrustPolicy,
8335
+ trustPolicy: this.inboundSeedTrustPolicy(),
7512
8336
  // A listener-only node accepts a wire-delivered seed solely against this
7513
8337
  // anchor (there is no per-call override on the inbound handler): with no
7514
8338
  // genesis/invite/operator pin it authorizes nobody, which is the point.
@@ -7577,6 +8401,18 @@ export class CadreNode implements SAppIdLookup {
7577
8401
  };
7578
8402
  }
7579
8403
 
8404
+ /**
8405
+ * The node-wide default trust policy for INBOUND seeds, the one value every
8406
+ * {@link SeedBootstrapService} this node constructs is given — the owner service,
8407
+ * the listener, and `applySeed`'s temp service — so the three cannot drift. The
8408
+ * claim policy when the node was started with `config.claim` (the two are refused
8409
+ * together at start, so there is nothing to merge), otherwise the configured
8410
+ * `seedTrustPolicy`, and `undefined` for the service's anchored default.
8411
+ */
8412
+ private inboundSeedTrustPolicy(): SeedTrustPolicy | undefined {
8413
+ return this.claimTrustPolicy ?? this.config.seedTrustPolicy;
8414
+ }
8415
+
7580
8416
  /**
7581
8417
  * Re-materialize the authorized-peer snapshot that the fail-closed per-stream
7582
8418
  * control-DB gate ({@link authorizeInboundControlStream}) judges against.
@@ -7714,6 +8550,10 @@ export class CadreNode implements SAppIdLookup {
7714
8550
  * Remove a previously-authorized peer from the cadre.
7715
8551
  * Signs the peer ID with the owner key and deletes the CadrePeer row.
7716
8552
  *
8553
+ * Removes the `CadrePeer` row only. The call that removes a machine completely (its owner
8554
+ * key, its push token and the invitations it could come back through, in one transaction)
8555
+ * is {@link removeMachine}.
8556
+ *
7717
8557
  * @param peerId - The peer ID to remove
7718
8558
  */
7719
8559
  async removePeer(peerId: string): Promise<void> {
@@ -7734,6 +8574,212 @@ export class CadreNode implements SAppIdLookup {
7734
8574
  });
7735
8575
  }
7736
8576
 
8577
+ /**
8578
+ * Seat another owner key, signed by this node's owner key (the signed-add branch of
8579
+ * `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
8580
+ * membership hub, so the refresh it triggers has run by the time this resolves.
8581
+ *
8582
+ * @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
8583
+ * @returns `true` when the row was seated, `false` when the key is already a live owner.
8584
+ */
8585
+ async addOwner(ownerPublicKeyB64: string): Promise<boolean> {
8586
+ if (!this.seedBootstrapService) {
8587
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8588
+ }
8589
+ const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
8590
+ const added = await this.seedBootstrapService.addOwner(key);
8591
+ // NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
8592
+ // update branch to re-touch, unlike CadrePeer), so it is only logged;
8593
+ // tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
8594
+ if (added && this.committedAlone()) {
8595
+ log('addOwner(%s) committed while ALONE (0 control connections): the new owner row is ' +
8596
+ 'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
8597
+ }
8598
+ return added;
8599
+ }
8600
+
8601
+ /**
8602
+ * Remove an owner key, signed by this node's owner key (which must be a different owner):
8603
+ * the delete and its `Revocation` tombstone commit together. Refused by name when the key
8604
+ * is this node's own or the only live owner. The write notifies the membership hub; the
8605
+ * tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
8606
+ * while alone is re-issued on cohort growth like every other removal.
8607
+ *
8608
+ * Removes the `OwnerKey` row only. The call that removes a machine completely, its owner key
8609
+ * included, is {@link removeMachine}.
8610
+ *
8611
+ * @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
8612
+ * @returns `true` when the row was removed, `false` when the key is not a live owner here.
8613
+ */
8614
+ async removeOwner(ownerPublicKeyB64: string): Promise<boolean> {
8615
+ if (!this.seedBootstrapService) {
8616
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8617
+ }
8618
+ const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
8619
+ return await this.seedBootstrapService.removeOwner(key);
8620
+ }
8621
+
8622
+ /**
8623
+ * The cadre's machines as an app lists them ({@link CadreMachine}): one entry per live
8624
+ * `CadrePeer` row, judged by this node's membership predicate, plus one per live `OwnerKey`
8625
+ * whose key names a machine with no row (`describeMachines` in `machine-removal.ts`).
8626
+ * Unlocked reads with the default retry.
8627
+ */
8628
+ async listMachines(): Promise<CadreMachine[]> {
8629
+ if (!this.controlDatabase) {
8630
+ throw new Error('CadreNode must be started before listing machines');
8631
+ }
8632
+ return (await this.readMachines(this.controlDatabase)).machines;
8633
+ }
8634
+
8635
+ /**
8636
+ * What {@link removeMachine} would do for `peerId`, read without writing, for an app's
8637
+ * confirmation step ({@link MachineRemovalPlan}): whether this node may remove it, whether its
8638
+ * owner key goes with it, which rows that key vouched (so the app can offer to keep them by
8639
+ * re-vouching), which invitations it could come back through, and whether this node's own
8640
+ * rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
8641
+ */
8642
+ async planMachineRemoval(peerId: string): Promise<MachineRemovalPlan> {
8643
+ if (!this.controlDatabase) {
8644
+ throw new Error('CadreNode must be started before planning a machine removal');
8645
+ }
8646
+ return (await this.planMachineRemovalWith(this.controlDatabase, peerId)).plan;
8647
+ }
8648
+
8649
+ /**
8650
+ * Remove a machine from the cadre completely, signed by this node's owner key: its
8651
+ * `CadrePeer` row, its `OwnerKey` row when its key is an owner, its `DeviceToken` row (a
8652
+ * removed phone must not be push-woken) and a withdrawal of every live invitation it could
8653
+ * redeem to come back, in ONE transaction ({@link ControlDatabase.removeMachine}), with a
8654
+ * `Revocation` tombstone per row so the removal holds on every machine
8655
+ * ("Deletes made while alone" in docs/architecture.md).
8656
+ *
8657
+ * Steps: re-plan ({@link planMachineRemoval}) and throw {@link MachineRemovalRefusedError} on a
8658
+ * refusal; check `options.revouch` against the plan's dependents and throw before any write
8659
+ * when it names anything else; **re-vouch first**, under this node's key, the dependents the
8660
+ * caller chose to keep (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they are
8661
+ * never untrusted in between, and stop before the removal if one fails (re-vouches already
8662
+ * made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
8663
+ * machine as a dial target, and the refresh the write notifies hangs up its control
8664
+ * connection ({@link hangUpRevokedControlPeers}) before this resolves.
8665
+ *
8666
+ * Not undone by this call: the machine keeps every row and strand block it already synced
8667
+ * and the party's strand member keys (`tickets/blocked/decide-removed-machine-strand-access`).
8668
+ * A fresh invitation re-admits it later under fresh stamps.
8669
+ *
8670
+ * @returns what was written ({@link MachineRemovalResult}); all false when no live row names
8671
+ * the machine here (already removed elsewhere).
8672
+ * @throws {MachineRemovalRefusedError} `'not-an-owner'`, `'self'` or `'last-owner'`.
8673
+ */
8674
+ async removeMachine(peerId: string, options: RemoveMachineOptions = {}): Promise<MachineRemovalResult> {
8675
+ const service = this.seedBootstrapService;
8676
+ if (!service || !this.controlDatabase) {
8677
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8678
+ }
8679
+ const { plan, rows } = await this.planMachineRemovalWith(this.controlDatabase, peerId);
8680
+ if (plan.refusal !== null) {
8681
+ throw new MachineRemovalRefusedError(peerId, plan.refusal);
8682
+ }
8683
+ const revouch = revouchWithinPlan(plan, options.revouch);
8684
+ if (plan.machine === null) {
8685
+ log('removeMachine(%s): no live row names it here; nothing to remove', peerId);
8686
+ return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [], revouched: { owners: [], peers: [] } };
8687
+ }
8688
+ await this.revouchDependents(service, revouch, rows);
8689
+ const writes = await service.removeMachine({
8690
+ peerId,
8691
+ ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
8692
+ inviteKeys: plan.invitations,
8693
+ });
8694
+ if (writes.removedPeer) {
8695
+ // Track + loudly flag a delete that committed local-only (security-relevant).
8696
+ this.noteControlWrite(peerId, 'remove');
8697
+ }
8698
+ // As removePeer: a removed machine is no longer a dial target, however this node learned
8699
+ // its address. A persist failure only lets the entry reappear after a restart.
8700
+ void this.bootstrapPeerStore?.forget(peerId).catch((error: unknown) => {
8701
+ log('removeMachine: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
8702
+ });
8703
+ log('removeMachine(%s): peer=%s ownerKey=%s deviceToken=%s invitations=%d revouched owners=%d peers=%d', peerId,
8704
+ writes.removedPeer, writes.removedOwnerKey, writes.removedDeviceToken, writes.withdrawnInvitations.length,
8705
+ revouch.owners.length, revouch.peers.length);
8706
+ return { ...writes, revouched: revouch };
8707
+ }
8708
+
8709
+ /**
8710
+ * The reads behind {@link listMachines}: the live `CadrePeer` and `OwnerKey` rows and the
8711
+ * machines projected from them, with this node's membership predicate as the `trusted` test.
8712
+ */
8713
+ private async readMachines(controlDatabase: ControlDatabase): Promise<{ machines: CadreMachine[]; rows: CadrePeerRow[]; owners: OwnerKeyRow[] }> {
8714
+ const [rows, owners] = await Promise.all([controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows()]);
8715
+ const chain = await this.chainFor(rows, this.invitationChainSource(controlDatabase, true));
8716
+ const selfPeerId = this.peerId?.toString() ?? null;
8717
+ const machines = describeMachines({
8718
+ partyId: this.partyId,
8719
+ selfPeerId,
8720
+ rows,
8721
+ owners,
8722
+ trusted: (row) => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain),
8723
+ });
8724
+ return { machines, rows, owners };
8725
+ }
8726
+
8727
+ /** {@link planMachineRemoval} with the rows it was judged from, which {@link removeMachine} re-vouches dependents by. */
8728
+ private async planMachineRemovalWith(controlDatabase: ControlDatabase, peerId: string): Promise<{ plan: MachineRemovalPlan; rows: CadrePeerRow[] }> {
8729
+ const { machines, rows, owners } = await this.readMachines(controlDatabase);
8730
+ const [invites, usages] = await Promise.all([controlDatabase.listCadreInviteStatuses(), controlDatabase.queryCadreInviteUsages()]);
8731
+ const plan = planMachineRemoval({
8732
+ peerId,
8733
+ machines,
8734
+ rows,
8735
+ owners,
8736
+ selfPeerId: this.peerId?.toString() ?? null,
8737
+ selfOwnerKey: this.ownerSigningKeyEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKeyB64 ?? null,
8738
+ invites,
8739
+ usages,
8740
+ });
8741
+ return { plan, rows };
8742
+ }
8743
+
8744
+ /**
8745
+ * The re-vouch step of {@link removeMachine}: every chosen dependent row is rebound to this
8746
+ * node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
8747
+ * Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
8748
+ * the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
8749
+ * (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
8750
+ *
8751
+ * The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
8752
+ * `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
8753
+ * would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
8754
+ * the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
8755
+ * bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
8756
+ * changes. A row never self-published carries no stamp to keep and gets the clock.
8757
+ *
8758
+ * A failure rethrows and stops the call before the removal; re-vouches already made stay
8759
+ * (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
8760
+ * committed alone is not re-issued on growth (the add half has no re-issue path,
8761
+ * `tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue`), so it is logged as
8762
+ * {@link addOwner} logs it; the peer re-touches are re-issued by the existing drain.
8763
+ */
8764
+ private async revouchDependents(
8765
+ service: SeedBootstrapService,
8766
+ revouch: MachineRemovalPlan['dependents'],
8767
+ rows: readonly CadrePeerRow[],
8768
+ ): Promise<void> {
8769
+ for (const key of revouch.owners) {
8770
+ await service.revouchOwner(key);
8771
+ if (this.committedAlone()) {
8772
+ log('removeMachine: re-vouch of owner %s committed while ALONE (0 control connections): the re-added ' +
8773
+ 'owner row is local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
8774
+ }
8775
+ }
8776
+ for (const dependent of revouch.peers) {
8777
+ const row = rows.find((candidate) => candidate.peerId === dependent);
8778
+ const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
8779
+ await service.reauthorizePeer(dependent, updatedAt);
8780
+ }
8781
+ }
8782
+
7737
8783
  /**
7738
8784
  * Create a seed from the current control network state.
7739
8785
  * The seed contains peer information and is signed by an owner.
@@ -7749,10 +8795,12 @@ export class CadreNode implements SAppIdLookup {
7749
8795
  * Apply a seed to populate the peer cache and enable connections.
7750
8796
  *
7751
8797
  * Validates the seed signature, then evaluates a trust anchor for the signer
7752
- * key (see `SeedTrustPolicy`). An enrollment caller can pass a per-seed
7753
- * `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
7754
- * `CadreInvite.ownerKeys` — so a cold-start node can accept its first
7755
- * seed without reconfiguring the service.
8798
+ * key (see `SeedTrustPolicy`). An operator-driven caller can pass a per-seed
8799
+ * `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a pinned
8800
+ * owner key — so a cold-start node can accept its first seed without
8801
+ * reconfiguring the service. The override wins on a node started with
8802
+ * `config.claim` as well: the claim secret is the node's default inbound policy,
8803
+ * not a lock on its local operator, who already controls the process.
7756
8804
  */
7757
8805
  async applySeed(
7758
8806
  seed: ControlNetworkSeed,
@@ -7766,13 +8814,13 @@ export class CadreNode implements SAppIdLookup {
7766
8814
  //
7767
8815
  // This temp service is discarded after the call, so it must NOT own the shared
7768
8816
  // node's inbound seed handler: pass { registerHandler: false }. That keeps
7769
- // repeated service-less applySeed/dialInvite idempotent (no handler leak, no
8817
+ // repeated service-less applySeed idempotent (no handler leak, no
7770
8818
  // DuplicateProtocolHandlerError). The temp service still applies this seed; a
7771
8819
  // node that wants to RECEIVE inbound seeds needs a persistent service
7772
8820
  // (enableSeedListener / initializeSeedBootstrap) to own the handler.
7773
8821
  const tempService = new SeedBootstrapService({
7774
8822
  partyId: seed.partyId,
7775
- trustPolicy: this.config.seedTrustPolicy,
8823
+ trustPolicy: this.inboundSeedTrustPolicy(),
7776
8824
  ...this.seedServiceBudgets(),
7777
8825
  // The anchor is node-scoped, not service-scoped: a throwaway service
7778
8826
  // must consult (and persist an accepted signer into) the SAME store the
@@ -7815,12 +8863,23 @@ export class CadreNode implements SAppIdLookup {
7815
8863
 
7816
8864
  /**
7817
8865
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
8866
+ *
8867
+ * `target` is a multiaddr string, or a peer id with its addresses (the form a phone
8868
+ * uses for a node it scanned), which are dialed one at a time before the request
8869
+ * (`SeedBootstrapService.deliverSeed`). `options.claimProof` rides beside the seed when
8870
+ * the target is a brand-new node being claimed (`claim-proof.ts`).
8871
+ *
8872
+ * @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it formed.
7818
8873
  */
7819
- async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<{ accepted: boolean; reason?: string }> {
8874
+ async deliverSeed(
8875
+ target: SeedDeliveryTarget,
8876
+ seed: ControlNetworkSeed,
8877
+ options?: { claimProof?: string },
8878
+ ): Promise<SeedAckMessage> {
7820
8879
  if (!this.seedBootstrapService) {
7821
8880
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
7822
8881
  }
7823
- return await this.seedBootstrapService.deliverSeed(targetMultiaddr, seed);
8882
+ return await this.seedBootstrapService.deliverSeed(target, seed, options);
7824
8883
  }
7825
8884
 
7826
8885
  /**
@@ -7893,32 +8952,63 @@ export class CadreNode implements SAppIdLookup {
7893
8952
  }
7894
8953
 
7895
8954
  /**
7896
- * Create an invite for a phone to join the cadre.
7897
- * Use when a server wants to invite a NAT'd phone.
8955
+ * Claim a node that was started with a claim secret (`CadreNodeConfig.claim`), from
8956
+ * the peer id, addresses and secret its host showed (the QR code): mint a seed, prove
8957
+ * possession of the secret beside it (`claim-proof.ts`), deliver it, and on acceptance
8958
+ * vouch the node into this cadre and keep its addresses as a dial target. The owner
8959
+ * side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
8960
+ *
8961
+ * Requires the seed service with an owner key ({@link initializeSeedBootstrap}), as
8962
+ * {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
8963
+ * second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
8964
+ * no retained dial target behind on this node — a phone that typed the wrong secret,
8965
+ * or scanned a node someone else already owns, has nothing to clean up.
8966
+ *
8967
+ * A failure is one of three kinds a caller can tell apart:
8968
+ *
8969
+ * - `ClaimRefusedError` — the node was reached and refused, with its refusal code when
8970
+ * its policy gave one.
8971
+ * - `PeerUnreachableError` — no connection to the node formed from any of its addresses,
8972
+ * so nothing was sent. Each address is dialed in turn on its own limit, so this can
8973
+ * take up to the per-peer dial limit plus the seed request's (114.5 s at the default
8974
+ * declared link) when none answers.
8975
+ * - anything else — the node was reached and the exchange failed, or a local step failed
8976
+ * (a malformed secret or address among them). Repeating the claim with the same secret
8977
+ * is safe (see below).
8978
+ *
8979
+ * Idempotent for the same owner: a second call after a dropped response is accepted
8980
+ * by the node (its anchored branch), the row insert is a no-op on an existing row
8981
+ * (`ControlDatabase.insertCadrePeer`), and the dial target is replaced, not duplicated.
8982
+ *
8983
+ * The first dial is kicked (not awaited) so it happens now rather than on the next
8984
+ * timed reconcile pass; a caller that wants to wait for the connection awaits
8985
+ * {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
8986
+ * in flight is joined rather than restarted. The claimed node dials this one back too,
8987
+ * from the seed's owner list, when this node has a listen or relay address; a phone
8988
+ * with neither is reached by its own reconcile pass alone.
8989
+ *
8990
+ * @throws {ClaimRefusedError} when the node was reached and refused the claim.
8991
+ * @throws {PeerUnreachableError} when the node was never reached.
7898
8992
  */
7899
- async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
8993
+ async claimNode(target: { peerId: string; multiaddrs: string[]; secret: string }): Promise<void> {
7900
8994
  if (!this.seedBootstrapService) {
7901
8995
  throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
7902
8996
  }
7903
- const result = await this.seedBootstrapService.createInvite(token, expiresIn);
7904
- // The invitee dials this node BEFORE it is authorized (dialInvite →
7905
- // acceptPhone), so hold the inbound connection gate open for strangers for
7906
- // the invite's validity (or a bounded default when it never expires).
7907
- this.openEnrollmentWindow(result.invite.expiresAt ?? Date.now() + DEFAULT_ENROLLMENT_WINDOW_MS);
7908
- return result;
7909
- }
7910
-
7911
- /**
7912
- * Accept a phone connection using an invite.
7913
- * Call this when a phone dials in with an invite token.
7914
- */
7915
- async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
7916
- if (!this.seedBootstrapService) {
7917
- throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
8997
+ const secret = parseClaimSecret(target.secret);
8998
+ const seed = await this.seedBootstrapService.createSeed();
8999
+ const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
9000
+ const ack = await this.seedBootstrapService.deliverSeed(
9001
+ { peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
9002
+ if (!ack.accepted) {
9003
+ throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
7918
9004
  }
7919
- // The just-accepted phone opens control-DB streams next; its `CadrePeer` insert
7920
- // notifies the membership hub, so it is already admitted when this resolves.
7921
- await this.seedBootstrapService.acceptPhone(options, issuedInvite);
9005
+ log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
9006
+ // The wrapper queues the write for re-replication: a phone claiming its first
9007
+ // always-on node has no control connection yet, so the insert commits local-only.
9008
+ await this.authorizePeer(target.peerId, target.multiaddrs);
9009
+ this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
9010
+ void this.reconcileControlCohort().catch((error) =>
9011
+ log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
7922
9012
  }
7923
9013
 
7924
9014
  /**
@@ -7936,47 +9026,261 @@ export class CadreNode implements SAppIdLookup {
7936
9026
  return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
7937
9027
  }
7938
9028
 
9029
+ // ============================================================================
9030
+ // Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
9031
+ // ============================================================================
9032
+
7939
9033
  /**
7940
- * Encode an invite for out-of-band delivery (QR, link, etc.).
9034
+ * Where a device could redeem a cadre invitation minted now from, once this machine is gone
9035
+ * ({@link CadreInvitationReach}): the same member selection {@link createCadreInvitation}
9036
+ * makes, judged without minting anything. `rentNode` reads it to choose between inviting a
9037
+ * provider's container and claiming it; `local-network` counts as not reachable there, since
9038
+ * the container is never on a member's LAN. Requires a started node.
7941
9039
  */
7942
- encodeInvite(invite: CadreInvite): string {
7943
- const json = JSON.stringify(invite);
7944
- return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
9040
+ async invitationReach(): Promise<CadreInvitationReach> {
9041
+ if (!this.controlNode) {
9042
+ throw new Error('CadreNode must be started before judging invitation reach');
9043
+ }
9044
+ return (await this.selectInvitationMembers()).reach;
9045
+ }
9046
+
9047
+ /**
9048
+ * The `members` a cadre invitation minted now would name, and how far they reach: one
9049
+ * selection behind both {@link createCadreInvitation} and {@link invitationReach}, so the
9050
+ * two cannot disagree.
9051
+ */
9052
+ private async selectInvitationMembers(): Promise<{ members: string[]; reach: CadreInvitationReach }> {
9053
+ const { siblings, connected } = await this.resolveInvitationSiblings();
9054
+ const members = selectCadreInvitationMembers(await this.resolveInviteAddresses(), siblings, connected);
9055
+ return { members, reach: cadreInvitationReach(members, this.controlNode!.peerId.toString()) };
9056
+ }
9057
+
9058
+ /**
9059
+ * Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
9060
+ * this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
9061
+ * owner key ({@link initializeSeedBootstrap}).
9062
+ *
9063
+ * The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
9064
+ * the private half rides in the returned bundle as the proof of possession. Whoever holds
9065
+ * the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
9066
+ * credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
9067
+ * hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
9068
+ * member that has not received it by replication seats it from the bundle), this node's
9069
+ * anchored owner keys — every current owner this node has verified, the out-of-band ones
9070
+ * and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
9071
+ * them and checks the member's reply against them; sourced from the node-local anchor
9072
+ * only, never the raw replicated `OwnerKey` table, because the device anchors whatever
9073
+ * arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
9074
+ * node's anchor; an empty anchor is refused because a reply could not be checked), and
9075
+ * at most six addresses: this machine's first ({@link resolveInviteAddresses}, which
9076
+ * honours pushed NAT addresses), then two each of up to three other members
9077
+ * (`selectCadreInvitationMembers`), so the encoded text fits a QR code.
9078
+ *
9079
+ * The result's `reach` says where a device could redeem it from once this machine is gone
9080
+ * (`cadreInvitationReach`, judged on the other members' addresses only), so an app can warn
9081
+ * before handing over an invitation only it could answer.
9082
+ *
9083
+ * The row commits locally and replicates like any other control write; minted while
9084
+ * alone (a phone with no connection), it reaches the other members through the
9085
+ * peer-join block catch-up on the next connection. The device can redeem at any member it
9086
+ * reaches meanwhile: a member that does not hold the row yet admits the device's connection
9087
+ * provisionally ({@link admitInboundControlConnection}) and seats the row from the bundle.
9088
+ *
9089
+ * @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
9090
+ * integer, or neither this machine nor any other member has an address.
9091
+ */
9092
+ async createCadreInvitation(options: CreateCadreInvitationOptions): Promise<CreateCadreInvitationResult> {
9093
+ const service = this.seedBootstrapService;
9094
+ if (!service?.canAuthorize()) {
9095
+ throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
9096
+ }
9097
+ const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
9098
+ if (ownerKeys.length === 0) {
9099
+ throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
9100
+ }
9101
+ const uses = options.uses ?? 1;
9102
+ if (!Number.isInteger(uses) || uses < 1) {
9103
+ throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
9104
+ }
9105
+ const { members, reach } = await this.selectInvitationMembers();
9106
+ if (members.length === 0) {
9107
+ throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
9108
+ }
9109
+ const expiresInMs = options.expiresInMs
9110
+ ?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
9111
+ const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
9112
+ const row = await service.insertCadreInvite({
9113
+ key: ed25519PublicKeyFromPrivate(invitePrivateKey),
9114
+ peerId: options.peerId ?? null,
9115
+ grantsOwner: options.grantsOwner,
9116
+ expiresAtMs: Date.now() + expiresInMs,
9117
+ totalUses: uses,
9118
+ });
9119
+ const invitation: CadreInvitation = {
9120
+ v: 1,
9121
+ partyId: this.config.controlNetwork.partyId,
9122
+ invitePrivateKey,
9123
+ invite: row,
9124
+ ownerKeys,
9125
+ members,
9126
+ };
9127
+ log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es), reach=%s)',
9128
+ row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length, reach);
9129
+ return { invitation, encoded: encodeCadreInvitation(invitation), reach };
7945
9130
  }
7946
9131
 
7947
9132
  /**
7948
- * Decode an invite from base64url encoding.
9133
+ * Every cadre invitation this node holds — minted here or replicated in — with its
9134
+ * standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
7949
9135
  */
7950
- decodeInvite(encoded: string): CadreInvite {
7951
- const bytes = uint8ArrayFromString(encoded, 'base64url');
7952
- const json = new TextDecoder().decode(bytes);
7953
- return JSON.parse(json) as CadreInvite;
9136
+ async listCadreInvitations(): Promise<CadreInviteStatus[]> {
9137
+ if (!this.controlDatabase) {
9138
+ throw new Error('CadreNode must be started before listing cadre invitations');
9139
+ }
9140
+ return await this.controlDatabase.listCadreInviteStatuses();
7954
9141
  }
7955
9142
 
7956
9143
  /**
7957
- * Dial an owner from an invite (for phone joining via invite).
9144
+ * Withdraw a cadre invitation: an owner-signed `Revocation` tombstone over its row, which
9145
+ * stays as the proof of membership for every device it admitted. No further redemption
9146
+ * succeeds anywhere the tombstone reaches, and a member holding it refuses the bundle's
9147
+ * copy of the row. A withdrawal committed while alone is re-issued on cohort growth like
9148
+ * any other tombstone.
9149
+ *
9150
+ * @returns `true` when this call filed the tombstone, `false` when the row is not held
9151
+ * here or was already withdrawn.
7958
9152
  */
7959
- async dialInvite(invite: CadreInvite): Promise<void> {
9153
+ async withdrawCadreInvitation(key: string): Promise<boolean> {
7960
9154
  if (!this.seedBootstrapService) {
7961
- // Create a temporary service that only dials the invite. It is discarded after
7962
- // the call, so it does NOT own the shared node's inbound seed handler:
7963
- // initialize with { registerHandler: false }. This temp dialInvite never
7964
- // applies a seed itself — a node that wants to RECEIVE an inbound seed back
7965
- // must have a persistent service (enableSeedListener / initializeSeedBootstrap)
7966
- // own the /sereus/seed/1.0.0 handler. trustPolicy is therefore effectively dead
7967
- // on this dial-only path; it is kept for symmetry with the applySeed temp site.
7968
- const tempService = new SeedBootstrapService({
7969
- partyId: invite.partyId,
7970
- trustPolicy: this.config.seedTrustPolicy,
7971
- ...this.seedServiceBudgets(),
7972
- });
7973
- if (this.controlNode && this.controlDatabase) {
7974
- await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
7975
- }
7976
- await tempService.dialInvite(invite);
7977
- return;
9155
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
9156
+ }
9157
+ return await this.seedBootstrapService.withdrawCadreInvite(key);
9158
+ }
9159
+
9160
+ /**
9161
+ * Join the cadre an invitation names by redeeming it at one of the member machines it
9162
+ * lists, the owner that minted it offline or not. Needs a started node with a stable
9163
+ * identity (`keyStore` or `privateKey`): the redemption is signed with the key behind
9164
+ * this node's peer id, and the member checks that the two match.
9165
+ *
9166
+ * Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
9167
+ * anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
9168
+ * does for a seed invite, so a later attempt or seed from the same owner is anchored);
9169
+ * sign the request; dial the listed members in order (`redeemAtMembers`); on
9170
+ * acceptance verify the reply against the pinned keys, merge its dial hints into the peer
9171
+ * store, retain the answering member as a cold-start dial target, refresh the membership
9172
+ * gate and start a cohort reconcile pass. This node then syncs the control database over
9173
+ * the connection it already holds, the same unified behaviour as after a seed.
9174
+ *
9175
+ * A device that is already a member (a restart with the invitation still configured) is
9176
+ * answered as accepted by the member's idempotent redemption, with no second usage row.
9177
+ *
9178
+ * One pass over the listed members by default. With `options.waitForMember` the call keeps
9179
+ * making passes while every answer is retryable — for a device redeeming while no
9180
+ * always-on member is up — until a member accepts, one refuses finally, the invitation
9181
+ * expires (thrown as the `invite-spent` refusal) or `options.signal` aborts
9182
+ * (`redeemInPasses`). The request is signed once, before the first pass, and every pass
9183
+ * sends it: its `usageStampId` keys the usage row, so a member that admitted this node on a
9184
+ * pass whose reply was lost answers the next as already a member. `options.onProgress`
9185
+ * reports each pass and pause.
9186
+ *
9187
+ * When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
9188
+ * row by consent. The caller still has to wire that key for signing afterwards, with
9189
+ * {@link wireOwnKeyIfOwner}; never `ensureOwnerKey`, the row is already there. Other
9190
+ * machines derive this node's key into their anchors from the row's stored proof on their
9191
+ * next membership refresh ({@link syncOwnerAnchor}).
9192
+ *
9193
+ * Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
9194
+ * forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
9195
+ * can echo the owner-signed row it was just sent; that is what pasting an attacker's
9196
+ * invitation means. What protects this node afterwards is its anchor: every row it then
9197
+ * syncs is judged against the pinned owner keys, so a machine that is not a member of the
9198
+ * cadre the invitation names can admit nobody this node will trust.
9199
+ *
9200
+ * @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
9201
+ * an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
9202
+ * listed address was tried without an acceptance (one pass only), the signal's reason on
9203
+ * an abort, and a plain `Error` for the preconditions above.
9204
+ */
9205
+ async redeemCadreInvitation(invitation: CadreInvitation, options: RedeemCadreInvitationOptions = {}): Promise<RedeemCadreInvitationResult> {
9206
+ const controlNode = this.controlNode;
9207
+ if (!controlNode || !this.controlDatabase || !this._running) {
9208
+ throw new Error('CadreNode must be started before redeeming a cadre invitation');
9209
+ }
9210
+ if (invitation.partyId !== this.config.controlNetwork.partyId) {
9211
+ throw new Error(`Cadre invitation is for party ${invitation.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
9212
+ }
9213
+ const signer = this.getSelfSigningKey();
9214
+ if (!signer) {
9215
+ throw new Error('redeemCadreInvitation: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the redemption');
9216
+ }
9217
+ await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
9218
+ const pinned = new Set(invitation.ownerKeys);
9219
+ const request = signRedeemRequest(
9220
+ invitation,
9221
+ { peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 },
9222
+ generateStampId(controlNode.peerId.toString()),
9223
+ await this.resolveInviteAddresses()
9224
+ );
9225
+ const { reply, memberPeerId, memberAddr } = await redeemInPasses(controlNode, {
9226
+ invitation,
9227
+ request,
9228
+ isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
9229
+ linkRoundTripMs: this.config.network?.linkRoundTripMs,
9230
+ waitForMember: options.waitForMember ?? false,
9231
+ ...(options.signal ? { signal: options.signal } : {}),
9232
+ ...(options.onProgress ? { onProgress: options.onProgress } : {}),
9233
+ });
9234
+ log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
9235
+
9236
+ const selfPeerId = controlNode.peerId.toString();
9237
+ await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
9238
+ if (memberPeerId !== null) {
9239
+ // Every address the bundle gave the answering member, so a restart has the same choices.
9240
+ const memberAddrs = this.parseMultiaddrs(invitation.members)
9241
+ .filter((addr) => trailingPeerId(addr) === memberPeerId)
9242
+ .map((addr) => addr.toString());
9243
+ this.retainDialTarget(memberPeerId, memberAddrs, 'redeemCadreInvitation');
7978
9244
  }
7979
- await this.seedBootstrapService.dialInvite(invite);
9245
+ // The pins above flip nothing yet (this node holds no rows), but the rows about to
9246
+ // replicate in are judged against them; refresh so the snapshot is not stale when they land.
9247
+ await this.refreshMembershipGate('invitation-redeemed');
9248
+ void this.reconcileControlCohort().catch((error) =>
9249
+ log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
9250
+ return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
9251
+ }
9252
+
9253
+ /**
9254
+ * If this node's identity key is a live `OwnerKey` here, wire it for signing
9255
+ * ({@link initializeSeedBootstrap}) and return true; otherwise change nothing and return
9256
+ * false. For a machine that joined someone else's cadre by invitation: after an
9257
+ * owner-granting redemption, and on every later start. A redemption seats the request's
9258
+ * `peerKey`, the key behind this node's peer id, so the identity key is the one to look for.
9259
+ *
9260
+ * Unlike `ensureOwnerKey` it never inserts an `OwnerKey` row, so a machine that joined a
9261
+ * cadre can never genesis-insert itself into it, and a non-owner never anchors its own key.
9262
+ * An owner whose key is already wired (an earlier call) is answered true with nothing redone.
9263
+ *
9264
+ * NOTE: a node running the listener-only seed service ({@link enableSeedListener}) cannot be
9265
+ * wired this way: libp2p takes one seed handler, and {@link initializeSeedBootstrap} would
9266
+ * register a second. Nothing that joins by invitation enables the listener today; if one
9267
+ * does, replace the listener here rather than beside it.
9268
+ *
9269
+ * @throws when the node is not started.
9270
+ */
9271
+ async wireOwnKeyIfOwner(): Promise<boolean> {
9272
+ if (!this.controlDatabase || !this._running) {
9273
+ throw new Error('CadreNode must be started before wiring its owner key');
9274
+ }
9275
+ const signingKey = this.getSelfSigningKey();
9276
+ if (!signingKey || !(await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64)) {
9277
+ log('wireOwnKeyIfOwner: this node\'s key is not an owner here; not wiring it');
9278
+ return false;
9279
+ }
9280
+ if (!this.seedBootstrapService?.canAuthorize()) {
9281
+ await this.initializeSeedBootstrap(signingKey.privateKeyB64);
9282
+ }
9283
+ return true;
7980
9284
  }
7981
9285
 
7982
9286
  // ============================================================================
@@ -8122,18 +9426,28 @@ export class CadreNode implements SAppIdLookup {
8122
9426
  }
8123
9427
 
8124
9428
  /**
8125
- * Addresses of the party's other machines for an invitation's bootstrap list
8126
- * ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
8127
- * member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
8128
- * is missing, stale or untrusted is left out; live connections only order them.
9429
+ * Addresses of the party's other machines for an open strand invitation's bootstrap list
9430
+ * ({@link createOpenInvitation}); {@link selectInvitationSiblingAddrs} picks which.
9431
+ */
9432
+ private async siblingInvitationAddrs(): Promise<string[]> {
9433
+ const { siblings, connected } = await this.resolveInvitationSiblings();
9434
+ return selectInvitationSiblingAddrs(siblings, connected);
9435
+ }
9436
+
9437
+ /**
9438
+ * The party's other machines an invitation may name — an open strand invitation
9439
+ * ({@link siblingInvitationAddrs}) or a cadre invitation ({@link createCadreInvitation}) —
9440
+ * and which of them this node is connected to. The source is each authorized member's
9441
+ * signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record is
9442
+ * missing, stale or untrusted is left out; live connections only order them.
8129
9443
  *
8130
9444
  * Best-effort: a failed read leaves the invitation naming this machine alone, which is what
8131
9445
  * it named before siblings were added.
8132
9446
  */
8133
- private async siblingInvitationAddrs(): Promise<string[]> {
9447
+ private async resolveInvitationSiblings(): Promise<{ siblings: InvitationSibling[]; connected: ReadonlySet<string> }> {
8134
9448
  const controlNode = this.controlNode;
8135
9449
  if (!controlNode || !this.controlDatabase) {
8136
- return [];
9450
+ return { siblings: [], connected: new Set() };
8137
9451
  }
8138
9452
  try {
8139
9453
  const siblings: InvitationSibling[] = [];
@@ -8143,11 +9457,10 @@ export class CadreNode implements SAppIdLookup {
8143
9457
  siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
8144
9458
  }
8145
9459
  }
8146
- const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
8147
- return selectInvitationSiblingAddrs(siblings, connected);
9460
+ return { siblings, connected: new Set(controlNode.getConnections().map((c) => c.remotePeer.toString())) };
8148
9461
  } catch (error) {
8149
- log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
8150
- return [];
9462
+ log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
9463
+ return { siblings: [], connected: new Set() };
8151
9464
  }
8152
9465
  }
8153
9466
 
@@ -8178,7 +9491,7 @@ export class CadreNode implements SAppIdLookup {
8178
9491
  disclosure,
8179
9492
  this.controlNode
8180
9493
  );
8181
- await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
9494
+ await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs, 'formStrand');
8182
9495
  // A closed host strand's approval carries the joiner's own membership invitation —
8183
9496
  // persist this party's identity key and stage the invitation for bring-up to redeem.
8184
9497
  // Runs AFTER the addr recording so a persistence failure (which throws — see the
@@ -8194,7 +9507,7 @@ export class CadreNode implements SAppIdLookup {
8194
9507
  * Ask to join through `invitation` and have the party keep trying until the join works, the
8195
9508
  * invitation is used up or expires, or the request is dismissed: across restarts, and from
8196
9509
  * any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
8197
- * offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
9510
+ * offline"). Records a party-wide `JoinRequest` row, runs one attempt on this machine at
8198
9511
  * once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
8199
9512
  * when it was unreachable or not ready, `'failed'` when it refused for good.
8200
9513
  *
@@ -8269,17 +9582,17 @@ export class CadreNode implements SAppIdLookup {
8269
9582
  }
8270
9583
 
8271
9584
  /**
8272
- * Write a join request's row, or adopt the party's existing row for the same invitation: a
8273
- * pending one as it is, a finished one replaced by `requested` (the user asked again).
9585
+ * Write a join request, or adopt the party's existing request for the same invitation: a
9586
+ * pending one as it is, a finished one rewritten as `requested` (the user asked again).
8274
9587
  */
8275
9588
  private async recordJoinRequest(
8276
9589
  database: ControlDatabase,
8277
- requested: PendingJoinFields,
9590
+ requested: JoinRequestFields,
8278
9591
  { ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
8279
- ): Promise<PendingJoinRow> {
8280
- let written: PendingJoinRow;
9592
+ ): Promise<PendingJoin> {
9593
+ let written: PendingJoin;
8281
9594
  try {
8282
- written = await database.insertPendingJoin(requested, ownerKey, signMessage);
9595
+ written = await database.insertJoinRequest(requested, ownerKey, signMessage);
8283
9596
  } catch (error) {
8284
9597
  if (!isPendingJoinConflict(error)) {
8285
9598
  throw error;
@@ -8292,12 +9605,12 @@ export class CadreNode implements SAppIdLookup {
8292
9605
  { cause: error }
8293
9606
  );
8294
9607
  }
8295
- if (existing.Outcome === null) {
9608
+ if (existing.outcome === null) {
8296
9609
  return existing;
8297
9610
  }
8298
- written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
9611
+ written = await database.rewritePendingJoin(existing.StampId, { ...requested, outcome: null }, ownerKey, signMessage);
8299
9612
  }
8300
- this.pendingJoinRunner?.noteWritten(written);
9613
+ this.pendingJoinRunner?.noteWritten(written, 'request');
8301
9614
  return written;
8302
9615
  }
8303
9616
 
@@ -8315,15 +9628,19 @@ export class CadreNode implements SAppIdLookup {
8315
9628
  selfId: this.controlNode!.peerId.toString(),
8316
9629
  linkRoundTripMs: this.config.network?.linkRoundTripMs,
8317
9630
  isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
8318
- // NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
8319
- // asked for a join the second reads a never-written block, which consults the cohort. If it
8320
- // shows up in a device profile, poll more slowly while the table reads empty.
9631
+ // NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
9632
+ // party that never asked for a join the second reads never-written blocks, which consults
9633
+ // the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
8321
9634
  readRows: () => database().queryPendingJoins(),
8322
9635
  readRow: (id) => database().queryPendingJoin(id),
8323
- attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
8324
- replace: async (expectedStampId, next) => {
9636
+ attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
9637
+ record: async (expected, outcome) => {
9638
+ const { ownerKey, signMessage } = await outcomeSigner();
9639
+ return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
9640
+ },
9641
+ rewrite: async (expectedStampId, next) => {
8325
9642
  const { ownerKey, signMessage } = await outcomeSigner();
8326
- return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
9643
+ return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
8327
9644
  },
8328
9645
  remove: async (id) => {
8329
9646
  const { ownerKey, signMessage } = await outcomeSigner();
@@ -8337,33 +9654,33 @@ export class CadreNode implements SAppIdLookup {
8337
9654
  this.pendingJoinRunner.start();
8338
9655
  }
8339
9656
 
8340
- /** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
8341
- private pendingJoinInvitation(row: PendingJoinRow): OpenInvitation {
9657
+ /** The invitation a `JoinRequest` row stores. A failure names the row, never the invitation, which is a bearer credential. */
9658
+ private pendingJoinInvitation(join: PendingJoin): OpenInvitation {
8342
9659
  try {
8343
- return this.decodeInvitation(row.Invitation);
9660
+ return this.decodeInvitation(join.Invitation);
8344
9661
  } catch {
8345
- throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
9662
+ throw new Error(`JoinRequest ${join.Id}: the stored invitation does not decode`);
8346
9663
  }
8347
9664
  }
8348
9665
 
8349
- private pendingJoinSAppId(row: PendingJoinRow): string {
9666
+ private pendingJoinSAppId(join: PendingJoin): string {
8350
9667
  try {
8351
- return this.decodeInvitation(row.Invitation).sAppId;
9668
+ return this.decodeInvitation(join.Invitation).sAppId;
8352
9669
  } catch {
8353
- log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
9670
+ log('JoinRequest %s: the stored invitation does not decode; reporting no sApp', join.Id);
8354
9671
  return '';
8355
9672
  }
8356
9673
  }
8357
9674
 
8358
9675
  /**
8359
- * Stage the membership invitation of each `joined` row this process has not staged yet, so a
9676
+ * Stage the membership invitation of each joined request this process has not staged yet, so a
8360
9677
  * closed strand joined on another owner machine is seated by whichever machine launches it
8361
9678
  * first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
8362
9679
  * seated. A strand that already has an invitation staged keeps it, for the reconciler to
8363
9680
  * settle first.
8364
9681
  */
8365
- private stageMembershipInvitesFromPendingJoins(rows: readonly PendingJoinRow[]): void {
8366
- for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
9682
+ private stageMembershipInvitesFromPendingJoins(joins: readonly PendingJoin[]): void {
9683
+ for (const { rowId, strandId, invite } of membershipInvitesToStage(joins, this.stagedMembershipInviteKeys, Date.now())) {
8367
9684
  this.stagedMembershipInviteKeys.add(invite.inviteKey);
8368
9685
  if (this.pendingMembershipInvites.has(strandId)) {
8369
9686
  continue;
@@ -8643,16 +9960,16 @@ export class CadreNode implements SAppIdLookup {
8643
9960
  * Nothing else would deliver them: the seed is read only at launch and resume, and the
8644
9961
  * refresh pass skips a peer FRET already holds a record for — the stale one, here.
8645
9962
  */
8646
- private async recordFormationStrandAddrs(strandId: string, strandAddrs: readonly string[]): Promise<void> {
9963
+ private async recordFormationStrandAddrs(strandId: string, strandAddrs: readonly string[], caller: string): Promise<void> {
8647
9964
  const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
8648
9965
  .flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
8649
9966
  if (attributed.length === 0) {
8650
- log('formStrand: responder disclosed no usable strand addrs for %s (%d carried) — cross-party seed unchanged',
8651
- strandId, strandAddrs.length);
9967
+ log('%s: no usable strand addrs for %s (%d carried) — cross-party seed unchanged',
9968
+ caller, strandId, strandAddrs.length);
8652
9969
  return;
8653
9970
  }
8654
9971
  this.formationStrandAddrs.set(strandId, attributed);
8655
- log('formStrand: kept %d of %d cross-party strand addr(s) for %s', attributed.length, strandAddrs.length, strandId);
9972
+ log('%s: kept %d of %d cross-party strand addr(s) for %s', caller, attributed.length, strandAddrs.length, strandId);
8656
9973
  const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
8657
9974
  if (runningNode) {
8658
9975
  await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);