@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -2,13 +2,45 @@ import debug from 'debug';
2
2
  import { createLibp2pNode } from '@optimystic/db-p2p';
3
3
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
4
4
  import { StrandDatabase } from './strand-database.js';
5
- import { StrandBackfill } from './strand-backfill.js';
5
+ import { PeerJoinBackfill } from './peer-join-backfill.js';
6
+ import { StrandRevocationEnforcer, createRevocationConnectionGater, readStrandRevocationRows } from './strand-revocation-enforcer.js';
7
+ import { StrandMembershipReconciler } from './strand-membership-reconciler.js';
8
+ import { removeMemberPeer } from './strand-membership-writer.js';
9
+ import { strandMemberKeyPair } from './strand-member-key.js';
6
10
  import { assertSchemaSignature } from './schema-verification.js';
7
- import { resolveStrandClusterSize, STRAND_CLUSTER_POLICY } from './types.js';
8
- import { resolveListenAddrs } from './relay-addrs.js';
9
- import { resolveAnnounceAddrs } from './announce-addrs.js';
11
+ import { StrandFirstSyncGate, StrandAwaitingFirstSyncError, strandFirstSyncComplete, DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS } from './strand-first-sync-gate.js';
12
+ import { resolveStrandClusterSize, strandClusterPolicy } from './types.js';
13
+ import { strandNodeAddrs } from './strand-network-config.js';
14
+ import { superviseRelayReservation } from './relay-reservation.js';
10
15
  const log = debug('sereus:cadre:strand-manager');
11
16
  const timing = debug('sereus:cadre:timing');
17
+ /**
18
+ * Whether a tracked instance is launched but waiting for its first sync: the runtime is
19
+ * up (`libp2pNode` set) while the database is still withheld (`database` unset). That
20
+ * is the first-sync gate's state regardless of what `status` says — the hibernation
21
+ * manager's idle timer can flip a long-gated joiner to `'idle'` without changing it.
22
+ *
23
+ * NOTE: `status` is therefore not the source of truth for "gated": a joiner that reaches
24
+ * nobody for `idleTimeout` (5 min at the interactive hint) reads `'idle'`, then
25
+ * `'hibernating'`, while still holding no database. Harmless today — every decision the
26
+ * runtime makes goes through this predicate, {@link StrandInstanceManager.publishDatabase}
27
+ * announces on the idle-flipped instance exactly as on a `'syncing'` one, and an app that
28
+ * shows "waiting for the other member" on `'syncing'` merely sees "idle" instead after
29
+ * five minutes. If an app ever needs the gated LABEL to survive the idle flip, make the
30
+ * hibernation manager's idle transition preserve `'syncing'` rather than adding a flag.
31
+ */
32
+ export function isAwaitingFirstSync(instance) {
33
+ return instance.libp2pNode !== undefined && instance.database === undefined;
34
+ }
35
+ /**
36
+ * The status a LIVE instance (runtime rebuilt or woken) should report: `'active'` once its
37
+ * database is published, `'syncing'` while the first-sync gate still withholds it. Every
38
+ * site that would otherwise write `'active'` after a build or a wake goes through this,
39
+ * so a gated joiner never reads as writable.
40
+ */
41
+ export function liveStrandStatus(instance) {
42
+ return instance.database ? 'active' : 'syncing';
43
+ }
12
44
  /**
13
45
  * Get the isolated storage path for a specific strand.
14
46
  *
@@ -82,6 +114,52 @@ export class StrandInstanceManager {
82
114
  * missed writes).
83
115
  */
84
116
  this.backfills = new Map();
117
+ /**
118
+ * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
119
+ * id. Same lifecycle rationale as {@link backfills}: created in
120
+ * `buildStrandRuntime`, stopped and dropped in `releaseRuntime`, so quiesce →
121
+ * resume rebuilds it with a fresh (initially empty, fail-open) snapshot.
122
+ */
123
+ this.revocationEnforcers = new Map();
124
+ /**
125
+ * The per-strand membership reconciler (closed strands launched with a party
126
+ * key only), keyed by strand id. Same lifecycle rationale as {@link backfills}:
127
+ * created in `buildStrandRuntime`, stopped and dropped in `releaseRuntime`, so
128
+ * quiesce → resume rebuilds it and re-runs the idempotent join ladder (redeem a
129
+ * staged invitation, write this machine's own `MemberPeer` binding) from scratch.
130
+ */
131
+ this.membershipReconcilers = new Map();
132
+ /**
133
+ * The per-strand first-sync gate (`strand-first-sync-gate.ts`), keyed by strand id:
134
+ * holds a launch's `StrandDatabase` while it is NOT yet published on the instance.
135
+ * Present from the moment the database object exists in `buildStrandRuntime` until
136
+ * {@link publishDatabase} hands it to the app — so it doubles as the rollback handle
137
+ * for a failed `initialize()` — and, for a joiner that holds no `Strand.Header` yet,
138
+ * for as long as the launch stays `'syncing'`. Same lifecycle as {@link backfills}:
139
+ * stopped (its database closed) and dropped in `releaseRuntime`, so a quiesce →
140
+ * resume rebuild re-probes over the same store. An entry and `instance.database`
141
+ * are mutually exclusive.
142
+ */
143
+ this.firstSyncGates = new Map();
144
+ /**
145
+ * Callers blocked in {@link whenWritable}, keyed by strand id. Resolved by
146
+ * {@link publishDatabase}, rejected by {@link stopStrand}; each waiter also carries
147
+ * its own timeout. Deliberately NOT cleared by `releaseRuntime`: a waiter outlives a
148
+ * quiesce → resume cycle, since the rebuild is what may finally publish the database.
149
+ */
150
+ this.writableWaiters = new Map();
151
+ /**
152
+ * The per-strand relay-reservation supervisors — ONE PER CONFIGURED RELAY, each
153
+ * over the node's own bare `/p2p-circuit` listener for that relay
154
+ * (`strand-network-config.ts`) — keyed by strand id. Same lifecycle rationale as
155
+ * {@link backfills}: started in `buildStrandRuntime` right after the libp2p node
156
+ * exists, stopped FIRST and dropped in `releaseRuntime` (before the database
157
+ * closes and the node stops, so no re-drive dials a node being torn down), so
158
+ * quiesce → resume rebuilds them over the new node. What makes a strand node's
159
+ * lost relay slot come back on its own — the control node has the same loop in
160
+ * `CadreNode.reserveRelays`.
161
+ */
162
+ this.relaySupervisors = new Map();
85
163
  /**
86
164
  * The resolved (cache-wrapped) raw storage per strand id — the instance's OWN
87
165
  * store, resolved once in `startStrand` and held until `stopStrand` disposes it.
@@ -146,6 +224,14 @@ export class StrandInstanceManager {
146
224
  throw new Error('StrandInstanceManager is stopping');
147
225
  }
148
226
  if (this.instances.has(strandId)) {
227
+ // Callers resolve founder-ness BEFORE reaching here (CadreNode.launchStrand) and
228
+ // honor a founder request on a tracked instance via foundExistingStrand — so a
229
+ // founder flag arriving at this early return against a non-founder retained
230
+ // config is a dropped bootstrap, and must never again be silent.
231
+ if (config.founder === true && this.launchConfigs.get(strandId)?.founder !== true) {
232
+ log('startStrand: strand %s is already running but was NOT launched as founder — ' +
233
+ 'this early return DROPS the founder request; use foundExistingStrand', strandId);
234
+ }
149
235
  log('Strand %s already running', strandId);
150
236
  return this.instances.get(strandId);
151
237
  }
@@ -212,8 +298,9 @@ export class StrandInstanceManager {
212
298
  * Build (or rebuild) the libp2p node + StrandDatabase for an instance and
213
299
  * attach them, transitioning it to `active`. Shared by `startStrand` (fresh
214
300
  * launch) and `resumeStrand` (rehydrating a quiesced instance). Reads all
215
- * volatile inputs (bootstrapNodes, network, profile, privateKey, sApp config)
216
- * from `config`, so the caller controls the cohort-derived values. Storage is the
301
+ * volatile inputs (bootstrapNodes, servingMachines, network, profile,
302
+ * privateKey, sApp config) from `config`, so the caller controls the
303
+ * cohort-derived values. Storage is the
217
304
  * one input it does NOT re-read from `config`: that belongs to the instance and
218
305
  * comes from `strandStorages`.
219
306
  */
@@ -232,16 +319,66 @@ export class StrandInstanceManager {
232
319
  // Determine relay mode: if explicitly set in config, use that;
233
320
  // otherwise default to true for storage profile nodes.
234
321
  const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
235
- // The CONFIGURED relay route (the default), deliberately NOT the control node's
236
- // `'search'` route: nothing drives an explicit reservation for a strand node, so
237
- // a bare `/p2p-circuit` search entry would register a pending reservation that
238
- // never gets filled and leave the strand node undialable. The ordering hazard
239
- // that pushed the control node onto the search route does not exist here — a
240
- // strand node's protocol ids are namespaced `/optimystic/strand-<id>/…`, so a
241
- // relay dialed from inside `libp2p.start()` is never in the strand's cohort and
242
- // cannot refuse its database bring-up. See `relay-addrs.ts`.
243
- const listenAddrs = resolveListenAddrs(config.network);
322
+ // The strand-node VIEW of the machine's one `NetworkConfig`, not the control
323
+ // node's resolution: fixed direct listen ports become ephemeral (two nodes cannot
324
+ // bind one port), the announce config is dropped (it names the control node's
325
+ // address), and every configured relay becomes one bare `/p2p-circuit` SEARCH
326
+ // listener plus a dial addr in `relayAddrs` for the per-relay supervisor started
327
+ // below — the same route the control node takes, because the configured
328
+ // `<relay>/p2p-circuit` shape loses its address on a relay restart, a hangup, or
329
+ // libp2p's own reservation refresh and recovers from none of them. See
330
+ // `strand-network-config.ts` for all three, and `relay-reservation.ts`.
331
+ //
332
+ // `relayAddrs` is destructured OFF: it is not a `createLibp2pNode` option, and
333
+ // spreading it would hand db-p2p a key it does not know.
334
+ const { relayAddrs: relayDialAddrs = [], ...addrOptions } = strandNodeAddrs(config.network);
335
+ // The CLOSED-strand revoked-peer gate (see strand-revocation-enforcer.ts).
336
+ // Constructed BEFORE the libp2p node because the node's options embed its
337
+ // predicates; its deny set starts empty (admit everything — fail-open, same
338
+ // posture as bring-up everywhere here) and is first populated after the
339
+ // strand database initializes below. Registered in the map immediately so
340
+ // the failure rollback (releaseRuntime) tears it down like every other
341
+ // runtime component. Open strands skip it entirely: their Member/MemberPeer
342
+ // tables are empty by schema (`OnlyClosed`), so there is nothing to derive
343
+ // a deny set from — arming would be pointless polling.
344
+ const revocationEnforcer = config.strandRow.Type === 'c' && config.revocationEnforcement?.enabled !== false
345
+ ? new StrandRevocationEnforcer({
346
+ label: strandId,
347
+ readRows: () => {
348
+ const database = instance.database;
349
+ if (!database) {
350
+ throw new Error(`strand ${strandId} has no live database`);
351
+ }
352
+ return readStrandRevocationRows(database.getDatabase());
353
+ },
354
+ // Read per sweep, never captured: this closure is built BEFORE the
355
+ // libp2p node exists (its options embed the enforcer's predicates),
356
+ // and `releaseRuntime` clears the field again on quiesce. Undefined
357
+ // therefore means "no transport right now", which the enforcer
358
+ // treats as "nothing to tear down".
359
+ getNetwork: () => instance.libp2pNode,
360
+ onSelfRevoked: () => config.onSelfRevoked?.(strandId)
361
+ }, config.revocationEnforcement)
362
+ : undefined;
363
+ if (revocationEnforcer) {
364
+ this.revocationEnforcers.set(strandId, revocationEnforcer);
365
+ }
366
+ // Closed strands compose revoked-peer denial onto the caller's gater and arm
367
+ // the fail-closed per-stream gate; open strands keep the raw configured gater
368
+ // (their peers are legitimately cross-party and nothing is ever revoked).
369
+ const revocationGateOptions = revocationEnforcer
370
+ ? {
371
+ connectionGater: createRevocationConnectionGater(revocationEnforcer, config.network?.connectionGater),
372
+ authorizeInboundStream: (remotePeerId, protocol) => revocationEnforcer.authorizeStream(remotePeerId, protocol)
373
+ }
374
+ : config.network?.connectionGater
375
+ ? { connectionGater: config.network.connectionGater }
376
+ : {};
244
377
  try {
378
+ // Bound once: the breadth is also the ceiling on the repair yardstick below, and the
379
+ // two must be derived from the same resolution. Inside the `try` deliberately — a
380
+ // rejected clusterSize is a build failure that runs the same cleanup as any other.
381
+ const strandClusterSize = resolveStrandClusterSize(config.clusterSize);
245
382
  let t0 = performance.now();
246
383
  const node = await createLibp2pNode({
247
384
  port: 0, // Random port
@@ -250,50 +387,54 @@ export class StrandInstanceManager {
250
387
  storage: strandStorage,
251
388
  fretProfile: config.profile === 'storage' ? 'core' : 'edge',
252
389
  relay: enableRelay,
253
- clusterSize: resolveStrandClusterSize(config.clusterSize),
390
+ clusterSize: strandClusterSize,
254
391
  // Deliberately NOT CONTROL_CLUSTER_POLICY: a strand is application data with its own
255
392
  // breadth reasoning, and the shape match with the control policy is a coincidence.
256
- clusterPolicy: STRAND_CLUSTER_POLICY,
393
+ //
394
+ // The builder declares this node's block-repair corroboration yardstick from the
395
+ // count of machines SERVING this strand, capped at the breadth above (a block
396
+ // never lives on more machines than the cohort is wide). Given no count — the
397
+ // production path today, since no per-strand serving count exists yet — it
398
+ // returns the frozen STRAND_CLUSTER_POLICY itself, declaring nothing. Resolved
399
+ // HERE rather than at `startStrand`, so a wake from hibernation would pick up a
400
+ // serving set that changed while the strand slept.
401
+ clusterPolicy: strandClusterPolicy(strandClusterSize, config.servingMachines),
257
402
  arachnode: {
258
403
  enableRingZulu: config.profile === 'storage'
259
404
  },
260
405
  ...(config.privateKey && { privateKey: config.privateKey }),
261
406
  ...(config.network?.transports && { transports: config.network.transports }),
262
- // NOTE: strand nodes receive the same RESOLVED listen addrs as the
263
- // control node. A fixed-port listen addr (e.g. cadre-cli's example
264
- // `/ip4/0.0.0.0/tcp/4001`) would have control + strand nodes racing to
265
- // bind one port — EADDRINUSE. Unverified; if a deployment configures a
266
- // fixed port and strands fail to start, rewrite the port per node here.
267
- // An inherited configured `/p2p-circuit` addr, by contrast, is deliberate —
268
- // it is what gives a NAT'd strand node a reachable relay slot — and works
269
- // because the launch path announces this strand's derived peerId to
270
- // the relay first (delegate admission; see cadre-node.ts). Those circuit
271
- // entries come either from a hand-written `network.listenAddrs` or from
272
- // `network.relayAddrs`, which `resolveListenAddrs` folds into the same list
273
- // on the configured route this call takes (see the comment above it).
274
- ...(listenAddrs && { listenAddrs }),
275
- // A strand node behind a reverse proxy has the same advertising need as the
276
- // control node, and inherits the same `NetworkConfig` — so it announces the same
277
- // addresses.
278
- //
279
- // NOTE: an announce addr naming a PORT therefore names the control node's port,
280
- // not this strand node's — the same second-node-on-one-host problem the listen
281
- // NOTE above describes, and the reason both belong to one fix. It is currently
282
- // unreachable: naming a fixed public port means configuring a fixed
283
- // `listenAddrs` port, and that collides at bind time before any strand node gets
284
- // far enough to advertise anything. Whatever gives strand nodes their own listen
285
- // port has to give them their own announce addrs in the same pass, or a strand
286
- // will advertise an address that reaches the control node. Tracked as an arm of
287
- // `tickets/backlog/strand-network-nat-relay-reachability.md`.
288
- ...resolveAnnounceAddrs(config.network),
289
- ...(config.network?.connectionGater && { connectionGater: config.network.connectionGater })
407
+ // Listen entries plus the WebSocket transport switch they imply — a strand node
408
+ // announces nothing the operator configured (`strand-network-config.ts`), and
409
+ // spreads AFTER `transports` above because the switch is a no-op whenever the
410
+ // embedder supplied its own factories. The bare `/p2p-circuit` entries (one per
411
+ // relay) open no connection here; each registers a pending reservation that the
412
+ // matching supervisor below fills, which is what gives a NAT'd strand node a
413
+ // reachable relay slot — and works against a party-run relay because the launch
414
+ // path announced this strand's derived peerId to it first (delegate admission;
415
+ // see cadre-node.ts).
416
+ ...addrOptions,
417
+ // The raw configured gater (open strands), or the revocation-composed
418
+ // gater plus the fail-closed per-stream revoked-peer gate (closed
419
+ // strands) — resolved above, before the try.
420
+ ...revocationGateOptions
290
421
  });
291
422
  timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
292
423
  instance.libp2pNode = node;
424
+ // One reservation supervisor PER RELAY, started now so the first attempts
425
+ // overlap the database bring-up below (the relay is never in a strand's
426
+ // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
427
+ // — so an open relay connection cannot disturb that bring-up). Their first
428
+ // attempts are awaited before the strand goes `active`, below. Registered in the
429
+ // map immediately so the failure rollback (releaseRuntime) stops them like every
430
+ // other runtime component.
431
+ this.relaySupervisors.set(strandId, this.startRelaySupervisors(strandId, node, relayDialAddrs, config));
293
432
  // Create and initialize the StrandDatabase.
294
433
  //
295
- // Attach before initialize so a failed init is cleaned up by
296
- // releaseRuntime below (close() is safe on a partially-initialized db).
434
+ // Held by the first-sync gate (registered before initialize) rather than attached
435
+ // to the instance: a failed init is then cleaned up by releaseRuntime below through
436
+ // the gate (close() is safe on a partially-initialized db), and the app only ever
437
+ // sees `instance.database` once this machine is allowed to write to the strand.
297
438
  t0 = performance.now();
298
439
  const strandDb = new StrandDatabase({
299
440
  strandId,
@@ -301,32 +442,104 @@ export class StrandInstanceManager {
301
442
  libp2pNode: node,
302
443
  coordinatedRepo: node.coordinatedRepo,
303
444
  // Founder bootstrap inputs: the strand's type drives which membership rows
304
- // are written, and the closed-strand MemberPrivateKey derives the founding
305
- // Member/Manager key. Both come off the control-network strand row.
445
+ // are written, and the PARTY's own key (partyMemberPrivateKey, resolved by the
446
+ // caller from the control-layer StrandPartyKey row) derives the founding
447
+ // Member/Manager key. The row's shared MemberPrivateKey rides along as the
448
+ // read-gating secret only — it derives nobody's identity.
306
449
  strandType: config.strandRow.Type,
307
450
  memberPrivateKey: config.strandRow.MemberPrivateKey ?? undefined,
451
+ partyMemberPrivateKey: config.partyMemberPrivateKey,
308
452
  founder: config.founder
309
453
  });
310
- instance.database = strandDb;
454
+ const gate = new StrandFirstSyncGate({
455
+ label: strandId,
456
+ database: strandDb,
457
+ onHeaderHeld: () => this.publishDatabase(instance, strandDb)
458
+ }, config.firstSync);
459
+ this.firstSyncGates.set(strandId, gate);
311
460
  await strandDb.initialize();
312
461
  timing('[buildStrandRuntime:%s] strandDatabase.initialize: %dms', strandId, Math.round(performance.now() - t0));
462
+ // The first-sync write gate. A founder just wrote the Header in its bootstrap; any
463
+ // other machine may commit only once it holds the Header — which it can only have
464
+ // received from a peer (now, or on an earlier run over the same store) — and has read
465
+ // each App table once, so their collections are fetched too. Publishing the database
466
+ // is what makes the strand writable to the app; until then the instance stays
467
+ // `'syncing'` and the gate re-probes on its cadence. Everything armed below reads
468
+ // `instance.database` lazily, so the reconciler and enforcer wait with it — and
469
+ // `publishDatabase` kicks the reconciler when the wait ends, so a joiner's membership
470
+ // rows land with the strand rather than one retry later.
471
+ t0 = performance.now();
472
+ if (config.founder === true || await strandFirstSyncComplete(strandDb.getDatabase(), strandId)) {
473
+ this.publishDatabase(instance, strandDb);
474
+ }
475
+ else {
476
+ gate.start();
477
+ }
478
+ timing('[buildStrandRuntime:%s] first-sync probe: %dms', strandId, Math.round(performance.now() - t0));
479
+ // The membership reconciler: finish this party's join on every machine —
480
+ // redeem a staged formation invitation if one is pending, then write the
481
+ // durable machine→party binding (Strand.MemberPeer). Armed here, the one
482
+ // seam where the strand's transport peer id and a live Database both exist
483
+ // for launch AND hibernation resume, for closed strands that carry the
484
+ // party's own membership key. start() kicks an immediate pass WITHOUT
485
+ // awaiting it — a joiner at this instant has not synced the founder's rows
486
+ // and may lack write quorum, so bring-up is never blocked; an unfinished join
487
+ // then retries on a short doubling ladder capped at the configured
488
+ // pollIntervalMs (which is also the flat cadence while nobody has admitted this
489
+ // party yet), and the loop stops once the member row and this machine's own
490
+ // binding are both in place. Armed AFTER the gate above, so a gated launch
491
+ // always has a reconciler registered by the time the gate can publish.
492
+ if (config.strandRow.Type === 'c' && config.partyMemberPrivateKey
493
+ && config.membershipReconciliation?.enabled !== false) {
494
+ const reconciler = new StrandMembershipReconciler({
495
+ label: strandId,
496
+ partyMemberPrivateKey: config.partyMemberPrivateKey,
497
+ // Read per pass, never captured — same lifecycle argument as the
498
+ // enforcer's getNetwork: quiesce drops both handles.
499
+ getDatabase: () => instance.database?.getDatabase(),
500
+ getOwnPeerId: () => instance.libp2pNode?.peerId.toString(),
501
+ pendingInvite: config.pendingMembershipInvite,
502
+ // The enforcer's CURRENT snapshot view of this node's own peer id — the
503
+ // reconciler stops rather than fight a self-revocation. Absent when the
504
+ // gate is disarmed (fail-open: the loop just runs).
505
+ isSelfRevoked: revocationEnforcer
506
+ ? () => {
507
+ const ownPeerId = instance.libp2pNode?.peerId.toString();
508
+ return ownPeerId !== undefined && revocationEnforcer.isRevoked(ownPeerId);
509
+ }
510
+ : undefined
511
+ }, {
512
+ pollIntervalMs: config.membershipReconciliation?.pollIntervalMs
513
+ ?? config.revocationEnforcement?.pollIntervalMs
514
+ });
515
+ this.membershipReconcilers.set(strandId, reconciler);
516
+ reconciler.start();
517
+ }
518
+ // Arm the revoked-peer deny-set poll now that the database it reads
519
+ // exists. start() kicks an immediate refresh WITHOUT awaiting it —
520
+ // bring-up and resume are never blocked on a membership read; until the
521
+ // first read lands the empty snapshot admits everyone (fail-open).
522
+ revocationEnforcer?.start();
313
523
  // Peer-join block catch-up: push this strand's own blocks to each newly
314
524
  // connected peer, so a machine that joined after blocks were committed
315
525
  // still ends up physically holding them (without per-strand storage there
316
- // is nothing to copy). Armed for EVERY stored strand: `StrandBackfill`
526
+ // is nothing to copy). Armed for EVERY stored strand: `PeerJoinBackfill`
317
527
  // only does work when the strand's libp2p node reports a peer connection,
318
528
  // so on a device that is genuinely alone it is inert, and arming it at
319
529
  // launch is what closes the "founded alone, never replicates" hole — a
320
530
  // peer that joins later gets the founder's blocks without any relaunch.
531
+ // No `authorizePeer` gate, deliberately — see the module comment in
532
+ // peer-join-backfill.ts for the strand-side argument (and why the control
533
+ // network, which DOES gate, is different).
321
534
  //
322
- // NOTE: cost is one StrandBackfill object + one `connection:open` listener
535
+ // NOTE: cost is one PeerJoinBackfill object + one `connection:open` listener
323
536
  // per running strand — linear in strand count, negligible at the handful a
324
537
  // device or host runs today. If a node ever hosts strands by the hundred,
325
538
  // move to one shared listener that dispatches by strand id.
326
539
  if (strandStorage && config.backfill?.enabled !== false) {
327
540
  if (node.keyNetwork) {
328
- const backfill = new StrandBackfill({
329
- strandId,
541
+ const backfill = new PeerJoinBackfill({
542
+ label: strandId,
330
543
  libp2p: node,
331
544
  peerNetwork: node.keyNetwork,
332
545
  storage: strandStorage,
@@ -341,8 +554,26 @@ export class StrandInstanceManager {
341
554
  log('Strand %s: libp2p node exposes no keyNetwork; peer-join block catch-up is inert', strandId);
342
555
  }
343
556
  }
344
- instance.status = 'active';
557
+ // The relay supervisors' FIRST attempts, all of them, before `active`: the happy
558
+ // path still publishes its circuit addr before `addStrand` resolves (the
559
+ // strand-addr RPC answers read it, and the same-party circuit scenario asserts
560
+ // it). Fail-SOFT — a first attempt that lands nothing does not fail the launch.
561
+ // The strand's database is up, and the supervisor keeps trying on its backoff;
562
+ // failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
563
+ //
564
+ // NOTE: a relay that is down costs this launch one full drive (10 s), and
565
+ // `StrandWatcher` launches strands one at a time — so N strands cost N × 10 s
566
+ // of bring-up during a relay outage. If that ever matters, stop awaiting here
567
+ // (the circuit addr then lands after `active`) rather than shortening the drive.
568
+ t0 = performance.now();
569
+ await this.awaitFirstRelayAttempts(strandId);
570
+ timing('[buildStrandRuntime:%s] relay first attempts: %dms', strandId, Math.round(performance.now() - t0));
571
+ instance.status = liveStrandStatus(instance);
345
572
  instance.lastActivity = new Date();
573
+ if (instance.status === 'syncing') {
574
+ log('Strand %s launched as a joiner with no Strand.Header held yet — writes are withheld until ' +
575
+ 'a member of the strand is reached', strandId);
576
+ }
346
577
  }
347
578
  catch (error) {
348
579
  // Roll back any partially-attached runtime so the instance is left with
@@ -356,17 +587,30 @@ export class StrandInstanceManager {
356
587
  }
357
588
  }
358
589
  /**
359
- * Release an instance's strand-network runtime: close the StrandDatabase, then
360
- * stop the libp2p node (construction order in reverse), clearing both fields
361
- * and zeroing connectedPeers. Tolerant of partially-built state — either handle
362
- * may be absent — so it doubles as rollback for a failed `buildStrandRuntime`.
363
- * Shared by `quiesceStrand`, `stopStrand`, and that rollback path.
590
+ * Release an instance's strand-network runtime: stop the relay supervisors and
591
+ * the other background loops, close the StrandDatabase, then stop the libp2p
592
+ * node (construction order in reverse), clearing both fields and zeroing
593
+ * connectedPeers. Tolerant of partially-built state — either handle may be
594
+ * absent — so it doubles as rollback for a failed `buildStrandRuntime`. Shared
595
+ * by `quiesceStrand`, `stopStrand`, and that rollback path.
364
596
  *
365
597
  * Leaves `strandStorages` untouched by design — the store outlives the runtime it
366
598
  * was built into, which is what makes a resume warm. Only `stopStrand` disposes it.
367
599
  */
368
600
  async releaseRuntime(instance) {
369
- // Backfill first — before the database closes and the libp2p node stops — so
601
+ // Relay supervisors FIRST of all — before anything below is torn down — so no
602
+ // re-drive is scheduled against a node being stopped. `stop()` is synchronous
603
+ // and never awaits a drive, so a relay that is down cannot delay this teardown.
604
+ // NOTE: a drive ALREADY in flight cannot be aborted (the drive takes no
605
+ // AbortSignal — `backlog/bug-relay-drive-not-cancellable`); it fails soft
606
+ // against the stopped node within its own 10 s deadline and its result is
607
+ // discarded.
608
+ const relaySupervisors = this.relaySupervisors.get(instance.strandId);
609
+ if (relaySupervisors) {
610
+ relaySupervisors.forEach((supervisor) => supervisor.stop());
611
+ this.relaySupervisors.delete(instance.strandId);
612
+ }
613
+ // Backfill next — before the database closes and the libp2p node stops — so
370
614
  // no NEW catch-up push is issued against a torn-down transport. A push already
371
615
  // in flight is not awaited; it fails into the module's own per-chunk catch.
372
616
  const backfill = this.backfills.get(instance.strandId);
@@ -374,6 +618,29 @@ export class StrandInstanceManager {
374
618
  backfill.stop();
375
619
  this.backfills.delete(instance.strandId);
376
620
  }
621
+ // The revoked-peer enforcer goes with the runtime it gated: a resume
622
+ // rebuilds it with a fresh (initially empty, fail-open) snapshot.
623
+ const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
624
+ if (revocationEnforcer) {
625
+ revocationEnforcer.stop();
626
+ this.revocationEnforcers.delete(instance.strandId);
627
+ }
628
+ // The membership reconciler likewise: a resume rebuilds it and re-runs the
629
+ // idempotent ladder, which is what makes resume-after-partial-join heal.
630
+ const membershipReconciler = this.membershipReconcilers.get(instance.strandId);
631
+ if (membershipReconciler) {
632
+ membershipReconciler.stop();
633
+ this.membershipReconcilers.delete(instance.strandId);
634
+ }
635
+ // A still-gated database (a joiner that never received the Header, or a launch whose
636
+ // initialize() failed) is closed through its gate; a published one through the
637
+ // instance. Never both — publishing moves the handle from the gate to the instance.
638
+ const gate = this.firstSyncGates.get(instance.strandId);
639
+ if (gate) {
640
+ gate.stop();
641
+ this.firstSyncGates.delete(instance.strandId);
642
+ await gate.database.close();
643
+ }
377
644
  if (instance.database) {
378
645
  await instance.database.close();
379
646
  instance.database = undefined;
@@ -384,6 +651,45 @@ export class StrandInstanceManager {
384
651
  }
385
652
  instance.connectedPeers = 0;
386
653
  }
654
+ /**
655
+ * One {@link superviseRelayReservation} per relay dial addr, each over exactly
656
+ * that relay so "held" is judged per relay (`circuitMultiaddrsVia`) and losing
657
+ * one relay re-drives only that one. Default timings — the control node's (2 s
658
+ * doubling to 60 s between failed attempts, a 5 s liveness check while held).
659
+ *
660
+ * Each supervisor's `beforeRedrive` is the caller's
661
+ * {@link StartStrandConfig.announceDelegateToRelay} for THIS relay and THIS
662
+ * node's peer id, so a re-drive after a relay restart is preceded by a fresh
663
+ * delegate grant on the relay about to be dialed. Returns `[]` when no relay is
664
+ * configured — the strand then simply has no circuit listener to fill.
665
+ */
666
+ startRelaySupervisors(strandId, node, relayDialAddrs, config) {
667
+ if (relayDialAddrs.length === 0) {
668
+ return [];
669
+ }
670
+ const announce = config.announceDelegateToRelay;
671
+ const delegatePeerId = node.peerId.toString();
672
+ return relayDialAddrs.map((relayAddr) => superviseRelayReservation(node, [relayAddr], {
673
+ ...(announce && { beforeRedrive: () => announce(strandId, relayAddr, delegatePeerId) })
674
+ }));
675
+ }
676
+ /**
677
+ * Wait for every relay supervisor's first attempt to settle, concurrently, and log
678
+ * the ones that landed nothing. Never throws — a strand whose relay is unreachable
679
+ * at launch still comes up, with its supervisors retrying in the background.
680
+ */
681
+ async awaitFirstRelayAttempts(strandId) {
682
+ const supervisors = this.relaySupervisors.get(strandId) ?? [];
683
+ if (supervisors.length === 0) {
684
+ return;
685
+ }
686
+ await Promise.all(supervisors.map((supervisor) => supervisor.firstAttempt));
687
+ supervisors.forEach((supervisor) => {
688
+ if (supervisor.lastError !== null) {
689
+ log('Strand %s: relay reservation not held after the first attempt (retrying in the background): %s', strandId, supervisor.lastError);
690
+ }
691
+ });
692
+ }
387
693
  /**
388
694
  * Drop the strand's owned store and release this strand's claim on its cache. The
389
695
  * wrapper counts holders, so the cache is emptied and unregistered from the shared
@@ -408,6 +714,84 @@ export class StrandInstanceManager {
408
714
  log('Failed to dispose storage cache for strand %s: %o', strandId, error);
409
715
  }
410
716
  }
717
+ /**
718
+ * Refresh a strand's revoked-peer deny set NOW — and, with it, run the
719
+ * teardown sweep that hangs up every connected revoked peer.
720
+ *
721
+ * The gate is otherwise poll-driven (default
722
+ * `DEFAULT_REVOCATION_POLL_INTERVAL_MS`), because neither a replicated
723
+ * revocation nor a local `revokeMember`/`leaveStrand` raises anything this
724
+ * runtime can hook. A caller that just wrote a revocation against the
725
+ * strand's database should follow it with this call, which makes the cut
726
+ * immediate instead of waiting out the interval.
727
+ *
728
+ * Quiet no-op — matching `quiesceStrand`'s posture — when the strand is not
729
+ * tracked, is quiesced, is open, or has the gate disabled: in every one of
730
+ * those cases there is no enforcer to refresh. Never rejects (the enforcer's
731
+ * refresh contains its own failures), and resolves only once the sweep has
732
+ * finished.
733
+ */
734
+ async refreshRevocationEnforcement(strandId) {
735
+ const enforcer = this.revocationEnforcers.get(strandId);
736
+ if (!enforcer) {
737
+ log('refreshRevocationEnforcement: strand %s has no armed revocation enforcer', strandId);
738
+ return;
739
+ }
740
+ await enforcer.refresh();
741
+ }
742
+ /**
743
+ * Best-effort removal of THIS machine's own `Strand.MemberPeer` binding — the
744
+ * self arm of `removeMemberPeer`, signed with the retained launch config's party
745
+ * key. Called by `CadreNode.unpublishStrand` BEFORE the local stop, while the
746
+ * strand database and transport are still live: after the unpublish commits, the
747
+ * party's `StrandPartyKey` control row is gone, so no restart could ever sign
748
+ * this removal again — the in-memory retained key is the last chance.
749
+ *
750
+ * Never throws. Quiet no-op for a strand that is untracked, open, or launched
751
+ * without a party key; a quiesced (hibernating) strand, or a strand whose write
752
+ * quorum is already unreachable, leaves the stale binding behind with a log —
753
+ * it grants nothing today (admission is not allowlist-gated) and only
754
+ * mis-credits diversity. The strand's own membership reconciler is stopped —
755
+ * and its in-flight pass awaited — FIRST, so a pass that was already past its
756
+ * own stopped check cannot re-register the binding this is about to clear.
757
+ *
758
+ * NOTE: deliberately NOT handled here (or anywhere yet): clearing the bindings
759
+ * of a machine removed from the CADRE at the control layer, or of a party's
760
+ * OTHER machines when the party unpublishes — the party still holds the key on
761
+ * every surviving machine and can clear from any of them, and the stale rows
762
+ * grant nothing while admission is deny-list only. Revisit when
763
+ * `feat-strand-member-allowlist-admission` lands and a stale binding starts
764
+ * granting admission rather than merely mis-crediting diversity.
765
+ */
766
+ async clearOwnMemberPeerBinding(strandId) {
767
+ const instance = this.instances.get(strandId);
768
+ const config = this.launchConfigs.get(strandId);
769
+ if (!instance || !config || config.strandRow.Type !== 'c' || !config.partyMemberPrivateKey) {
770
+ return;
771
+ }
772
+ const reconciler = this.membershipReconcilers.get(strandId);
773
+ if (reconciler) {
774
+ reconciler.stop();
775
+ await reconciler.settle();
776
+ }
777
+ const db = instance.database?.getDatabase();
778
+ const peerId = instance.libp2pNode?.peerId.toString();
779
+ if (!db || !peerId) {
780
+ log('clearOwnMemberPeerBinding: strand %s has no live runtime — own MemberPeer binding left in place', strandId);
781
+ return;
782
+ }
783
+ try {
784
+ // Background write on the app's database: never join a transaction the app has open.
785
+ await removeMemberPeer(db, {
786
+ memberKeyPair: strandMemberKeyPair(config.partyMemberPrivateKey),
787
+ peerId
788
+ }, { joinOpenTransaction: false });
789
+ log('clearOwnMemberPeerBinding: strand %s — own binding for peer %s cleared', strandId, peerId);
790
+ }
791
+ catch (error) {
792
+ log('clearOwnMemberPeerBinding: strand %s — best-effort clear failed (strand may already be unreachable): %o', strandId, error);
793
+ }
794
+ }
411
795
  /**
412
796
  * Quiesce a strand: release its strand-network resources (stop the libp2p node,
413
797
  * close the StrandDatabase) while RETAINING the instance record — identity,
@@ -434,9 +818,9 @@ export class StrandInstanceManager {
434
818
  * Resume a previously-quiesced strand: rebuild its libp2p node + StrandDatabase
435
819
  * from the retained launch config and re-attach them, transitioning it back to
436
820
  * `active`. `overrides` re-applies volatile inputs that may have changed since
437
- * launch (the cohort `bootstrapNodes` seed) and updates the retained config so
438
- * a later resume reuses the latest values. Returns the live instance unchanged
439
- * if it is already running.
821
+ * launch (the cohort `bootstrapNodes` seed and the strand's `servingMachines`
822
+ * count) and updates the retained config so a later resume reuses the latest
823
+ * values. Returns the live instance unchanged if it is already running.
440
824
  */
441
825
  async resumeStrand(strandId, overrides) {
442
826
  if (this.stopping) {
@@ -457,9 +841,20 @@ export class StrandInstanceManager {
457
841
  log('Resuming strand instance: %s', strandId);
458
842
  const tTotal = performance.now();
459
843
  // Re-apply volatile inputs and persist them so a subsequent resume reuses them.
844
+ // Each `??` matters: a resume that passes no override must keep the retained value,
845
+ // and a resume that passes one must leave it retained for the next resume — otherwise
846
+ // a later no-override wake silently reverts to whatever launch time saw.
460
847
  const resumeConfig = {
461
848
  ...launchConfig,
462
- bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes
849
+ bootstrapNodes: overrides?.bootstrapNodes ?? launchConfig.bootstrapNodes,
850
+ // NOTE: `??` retains, so an override can raise or lower the count but cannot CLEAR it
851
+ // back to "this node no longer knows" — the direction that would declare nothing. Moot
852
+ // while nothing feeds `servingMachines` at all; if a source lands
853
+ // (`backlog/feat-strand-yardstick-from-serving-machines`) that can legitimately lose the
854
+ // count — a strand whose member rows became unreadable — this merge must gain an explicit
855
+ // clear rather than silently declaring a stale number over a serving set it can no longer
856
+ // see. Same applies to `bootstrapNodes` above, where a stale seed is harmless.
857
+ servingMachines: overrides?.servingMachines ?? launchConfig.servingMachines
463
858
  };
464
859
  this.launchConfigs.set(strandId, resumeConfig);
465
860
  instance.status = 'starting';
@@ -476,6 +871,223 @@ export class StrandInstanceManager {
476
871
  throw error;
477
872
  }
478
873
  }
874
+ /**
875
+ * Honor a founder request against an ALREADY-TRACKED strand — the seam that
876
+ * closes the "whoever launches first decides whether the bootstrap runs" gap:
877
+ * an instance first launched as a joiner (an app's own attach, or a watcher
878
+ * poll winning the launch race) used to swallow a later founder request
879
+ * silently, leaving the strand active with no `Strand.Header`.
880
+ *
881
+ * Flips the RETAINED launch config's `founder` to true, so every later
882
+ * quiesce → resume rebuild founds as well (the bootstrap is insert-if-absent —
883
+ * {@link StrandDatabase.ensureFounderBootstrap} — so re-running it per rebuild
884
+ * writes nothing twice), and runs the bootstrap against the live database now.
885
+ *
886
+ * @returns how the request resolved:
887
+ * - `'already-founder'` — the retained config already founds; nothing to do.
888
+ * - `'bootstrapped'` — config flipped and the live database ran the bootstrap.
889
+ * - `'needs-resume'` — config flipped, but the instance is quiesced (no live
890
+ * database), so the bootstrap could not run here: the CALLER must wake the
891
+ * strand (`CadreNode.wakeStrand`, which owns the hibernation bookkeeping this
892
+ * manager does not) so the rebuild — which now founds — runs it.
893
+ * @param resolvePartyKey - Asked for the party's own membership key when (and only
894
+ * when) a CLOSED strand's retained config carries none — the instance was launched
895
+ * as a joiner before its `StrandPartyKey` row existed or replicated. The resolved
896
+ * key is retained alongside the founder flip so the bootstrap (now, or on the
897
+ * caller's wake for `'needs-resume'`) can seat the founding Member/Manager.
898
+ * @throws when the strand is not tracked — this seam exists only for the
899
+ * tracked-instance launch path; an untracked id is a caller bug.
900
+ * @throws whatever the live bootstrap throws (e.g. `PreSplitStrandIdentityError`),
901
+ * after {@link withdrawFounderRequest} — so a retry re-attempts the founding rather
902
+ * than resolving `'already-founder'` over an instance that never founded. A caller
903
+ * whose `'needs-resume'` wake fails owes the same withdrawal.
904
+ */
905
+ async foundExistingStrand(strandId, resolvePartyKey) {
906
+ const instance = this.instances.get(strandId);
907
+ const config = this.launchConfigs.get(strandId);
908
+ if (!instance || !config) {
909
+ throw new Error(`Cannot found strand ${strandId}: not tracked`);
910
+ }
911
+ if (config.founder === true) {
912
+ return 'already-founder';
913
+ }
914
+ const partyMemberPrivateKey = config.partyMemberPrivateKey
915
+ ?? (config.strandRow.Type === 'c' ? await resolvePartyKey?.() : undefined);
916
+ // A fresh object rather than mutating in place: startStrand retains the CALLER'S
917
+ // config object, which is not ours to rewrite.
918
+ this.launchConfigs.set(strandId, { ...config, founder: true, partyMemberPrivateKey });
919
+ if (!instance.database && !this.firstSyncGates.has(strandId)) {
920
+ return 'needs-resume';
921
+ }
922
+ try {
923
+ await this.ensureFounderBootstrap(strandId);
924
+ }
925
+ catch (error) {
926
+ this.withdrawFounderRequest(strandId);
927
+ throw error;
928
+ }
929
+ return 'bootstrapped';
930
+ }
931
+ /**
932
+ * Whether `strandId` is launched but still waiting for its first sync — a joiner whose
933
+ * runtime is up while its database is withheld (status `'syncing'`, or `'idle'` after
934
+ * the hibernation manager's idle timer fired on it). `false` for a writable, quiesced,
935
+ * or untracked strand. The predicate {@link whenWritable}'s callers gate on.
936
+ */
937
+ isAwaitingFirstSync(strandId) {
938
+ const instance = this.instances.get(strandId);
939
+ return instance !== undefined && isAwaitingFirstSync(instance);
940
+ }
941
+ /**
942
+ * Resolve once the strand's database is published to the app — immediately for a
943
+ * writable strand — or reject with {@link StrandAwaitingFirstSyncError} after
944
+ * `timeoutMs` (default: the retained launch config's `firstSync.timeoutMs`, else
945
+ * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}). The rejection is RETRYABLE: nothing is
946
+ * torn down, the gate keeps probing, and a later call waits afresh. A quiesced
947
+ * (hibernating) strand is not woken here — the wait spans a resume, so a caller that
948
+ * wants one wakes it (`CadreNode.wakeStrand`) and waits. Rejects immediately for an
949
+ * untracked strand, and whenever the strand is stopped while a wait is pending.
950
+ */
951
+ whenWritable(strandId, options) {
952
+ const instance = this.instances.get(strandId);
953
+ if (!instance) {
954
+ return Promise.reject(new Error(`Cannot wait for strand ${strandId} to become writable: not tracked`));
955
+ }
956
+ if (instance.database) {
957
+ return Promise.resolve(instance);
958
+ }
959
+ const timeoutMs = options?.timeoutMs
960
+ ?? this.launchConfigs.get(strandId)?.firstSync?.timeoutMs
961
+ ?? DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS;
962
+ const startedAt = Date.now();
963
+ return new Promise((resolve, reject) => {
964
+ const waiters = this.writableWaiters.get(strandId) ?? new Set();
965
+ this.writableWaiters.set(strandId, waiters);
966
+ const settle = () => {
967
+ clearTimeout(timer);
968
+ waiters.delete(waiter);
969
+ if (waiters.size === 0) {
970
+ this.writableWaiters.delete(strandId);
971
+ }
972
+ };
973
+ const waiter = {
974
+ resolve: () => { settle(); resolve(instance); },
975
+ reject: (error) => { settle(); reject(error); }
976
+ };
977
+ const timer = setTimeout(() => waiter.reject(new StrandAwaitingFirstSyncError(strandId, Date.now() - startedAt)), timeoutMs);
978
+ timer.unref?.();
979
+ waiters.add(waiter);
980
+ });
981
+ }
982
+ /**
983
+ * Hand a launch's database to the app: set `instance.database`, retire the gate that
984
+ * held it, release every {@link whenWritable} waiter, and — when the launch had already
985
+ * been reported gated — flip it `'active'` and announce it through the retained config's
986
+ * `onWritable`. "Reported gated" is any status but `'starting'`: `'syncing'`, or the
987
+ * `'idle'` the hibernation manager's idle timer flips a long-gated joiner to (see
988
+ * {@link isAwaitingFirstSync}) — the Header arriving after that flip must announce too,
989
+ * or an app hanging on `strand:writable` never learns the strand opened. A publish that
990
+ * lands while the instance is still `'starting'` (the Header arrived during the rest of
991
+ * bring-up) announces nothing: `startStrand` / `resumeStrand` are about to report the
992
+ * strand `'active'`, and `strand:writable` is defined as the follow-up to a gated
993
+ * launch, never a duplicate of `strand:started`.
994
+ *
995
+ * It is also the one seam where a WITHHELD database becomes available, so it kicks the
996
+ * membership reconciler: on a gated launch that loop's own immediate pass ran while
997
+ * `instance.database` was still unset, found no database, and would otherwise not try
998
+ * again until its retry timer fired. A launch that was never gated publishes here BEFORE
999
+ * the reconciler is constructed, so there is nothing registered to kick and the loop's own
1000
+ * immediate pass already sees the database — which is why the kick needs no `wasGated`
1001
+ * test of its own.
1002
+ */
1003
+ publishDatabase(instance, database) {
1004
+ const strandId = instance.strandId;
1005
+ const gate = this.firstSyncGates.get(strandId);
1006
+ if (gate) {
1007
+ gate.open();
1008
+ this.firstSyncGates.delete(strandId);
1009
+ }
1010
+ instance.database = database;
1011
+ instance.lastActivity = new Date();
1012
+ const wasGated = instance.status !== 'starting';
1013
+ if (wasGated) {
1014
+ instance.status = 'active';
1015
+ log('Strand %s is now writable: Strand.Header held', strandId);
1016
+ }
1017
+ const waiters = this.writableWaiters.get(strandId);
1018
+ if (waiters) {
1019
+ this.writableWaiters.delete(strandId);
1020
+ waiters.forEach((waiter) => waiter.resolve());
1021
+ }
1022
+ if (wasGated) {
1023
+ this.launchConfigs.get(strandId)?.onWritable?.(strandId);
1024
+ }
1025
+ // Not awaited: publishing must stay synchronous, and the pass chains on the loop's own
1026
+ // tail, so it cannot overlap a pass already in flight.
1027
+ void this.membershipReconcilers.get(strandId)?.reconcile();
1028
+ }
1029
+ /** Reject every {@link whenWritable} waiter for a strand that is going away. */
1030
+ rejectWritableWaiters(strandId, reason) {
1031
+ const waiters = this.writableWaiters.get(strandId);
1032
+ if (!waiters) {
1033
+ return;
1034
+ }
1035
+ this.writableWaiters.delete(strandId);
1036
+ waiters.forEach((waiter) => waiter.reject(new Error(`Strand ${strandId} ${reason} before becoming writable`)));
1037
+ }
1038
+ /**
1039
+ * Undo {@link foundExistingStrand}'s founder flip after the founding it promised
1040
+ * failed, so the retained config again says what this instance actually runs as (a
1041
+ * joiner) and the next founder request re-attempts the bootstrap. The resolved party
1042
+ * key stays retained — it is this party's identity for the strand either way. No-op
1043
+ * when the strand is untracked or its config does not found.
1044
+ */
1045
+ withdrawFounderRequest(strandId) {
1046
+ const config = this.launchConfigs.get(strandId);
1047
+ if (config?.founder === true) {
1048
+ this.launchConfigs.set(strandId, { ...config, founder: false });
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Run the (idempotent) founder bootstrap against a tracked strand's LIVE
1053
+ * database, independently of what the retained launch config says.
1054
+ *
1055
+ * Separate from {@link foundExistingStrand} because a caller that resolved
1056
+ * `'needs-resume'` and woke the strand must not assume the wake's own rebuild
1057
+ * founded it: `HibernationManager` COALESCES wakes, so a wake already in flight
1058
+ * when the config flipped had already read the PRE-flip config and rebuilt as a
1059
+ * joiner. Re-running the bootstrap costs one insert-if-absent probe per table
1060
+ * and is the only thing that makes "founding resolves once the Header is
1061
+ * written" true on that path.
1062
+ *
1063
+ * A strand still `'syncing'` (its database withheld behind the first-sync gate) is
1064
+ * founded through the gate: the bootstrap writes the Header this machine was waiting
1065
+ * to receive, so a successful run publishes the database and the strand goes
1066
+ * `'active'` — the one legitimate way a joiner launch becomes writable without a peer.
1067
+ *
1068
+ * @throws when the strand is not tracked, or is still quiesced (no live
1069
+ * database) — both mean the bootstrap did NOT run, which a founder request
1070
+ * must never swallow.
1071
+ */
1072
+ async ensureFounderBootstrap(strandId) {
1073
+ const instance = this.instances.get(strandId);
1074
+ if (!instance) {
1075
+ throw new Error(`Cannot run the founder bootstrap for strand ${strandId}: not tracked`);
1076
+ }
1077
+ const gate = this.firstSyncGates.get(strandId);
1078
+ const database = instance.database ?? gate?.database;
1079
+ if (!database) {
1080
+ throw new Error(`Cannot run the founder bootstrap for strand ${strandId}: it is quiesced, so there ` +
1081
+ 'is no live database to write to — resume it first.');
1082
+ }
1083
+ // Forward the RETAINED config's party key: a foundExistingStrand that flipped a
1084
+ // joiner launch to founder may have resolved a key the live database's captured
1085
+ // config (built at the original launch) never saw.
1086
+ await database.ensureFounderBootstrap(this.launchConfigs.get(strandId)?.partyMemberPrivateKey);
1087
+ if (!instance.database) {
1088
+ this.publishDatabase(instance, database);
1089
+ }
1090
+ }
479
1091
  /**
480
1092
  * Stop a strand instance
481
1093
  */
@@ -487,6 +1099,7 @@ export class StrandInstanceManager {
487
1099
  }
488
1100
  log('Stopping strand instance: %s', strandId);
489
1101
  instance.status = 'stopping';
1102
+ this.rejectWritableWaiters(strandId, 'was stopped');
490
1103
  try {
491
1104
  await this.releaseRuntime(instance);
492
1105
  instance.status = 'stopped';