@serfab/cadre-core 1.17.0 → 1.18.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 (144) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1176 -564
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +14 -13
  35. package/dist/index.js +16 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +89 -20
  56. package/dist/peer-join-backfill.js +265 -108
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/strand-database.d.ts +23 -2
  65. package/dist/strand-database.js +26 -6
  66. package/dist/strand-database.js.map +1 -1
  67. package/dist/strand-first-sync-gate.d.ts +176 -89
  68. package/dist/strand-first-sync-gate.js +288 -122
  69. package/dist/strand-first-sync-gate.js.map +1 -1
  70. package/dist/strand-formation-deadlines.d.ts +9 -9
  71. package/dist/strand-formation-deadlines.js +9 -9
  72. package/dist/strand-formation-manager.d.ts +23 -17
  73. package/dist/strand-formation-manager.js +46 -44
  74. package/dist/strand-formation-manager.js.map +1 -1
  75. package/dist/strand-formation-protocol.d.ts +5 -8
  76. package/dist/strand-formation-protocol.js +101 -16
  77. package/dist/strand-formation-protocol.js.map +1 -1
  78. package/dist/strand-instance-manager.d.ts +26 -17
  79. package/dist/strand-instance-manager.js +119 -50
  80. package/dist/strand-instance-manager.js.map +1 -1
  81. package/dist/strand-membership-reconciler.d.ts +16 -13
  82. package/dist/strand-membership-reconciler.js +30 -22
  83. package/dist/strand-membership-reconciler.js.map +1 -1
  84. package/dist/strand-membership-writer.d.ts +40 -28
  85. package/dist/strand-membership-writer.js +212 -153
  86. package/dist/strand-membership-writer.js.map +1 -1
  87. package/dist/strand-move.js +5 -2
  88. package/dist/strand-move.js.map +1 -1
  89. package/dist/strand-revocation-enforcer.d.ts +134 -60
  90. package/dist/strand-revocation-enforcer.js +144 -71
  91. package/dist/strand-revocation-enforcer.js.map +1 -1
  92. package/dist/strand-solicitation.d.ts +50 -39
  93. package/dist/strand-solicitation.js +10 -3
  94. package/dist/strand-solicitation.js.map +1 -1
  95. package/dist/strand-watcher.d.ts +80 -13
  96. package/dist/strand-watcher.js +152 -48
  97. package/dist/strand-watcher.js.map +1 -1
  98. package/dist/table-change-watch.d.ts +63 -0
  99. package/dist/table-change-watch.js +209 -0
  100. package/dist/table-change-watch.js.map +1 -0
  101. package/dist/timeout-scheduler.d.ts +4 -3
  102. package/dist/timeout-scheduler.js +4 -3
  103. package/dist/timeout-scheduler.js.map +1 -1
  104. package/dist/types.d.ts +90 -49
  105. package/dist/types.js +4 -4
  106. package/dist/types.js.map +1 -1
  107. package/package.json +8 -8
  108. package/src/cadre-invite-protocol.ts +119 -31
  109. package/src/cadre-node.ts +1282 -584
  110. package/src/control-cohort.ts +86 -9
  111. package/src/control-database.ts +848 -403
  112. package/src/control-formation-recorder.ts +359 -353
  113. package/src/control-protocol-guard.ts +16 -2
  114. package/src/control-schema-version.ts +101 -51
  115. package/src/control-schema.ts +16 -16
  116. package/src/control-write-retry.ts +91 -19
  117. package/src/delegate-admission.ts +351 -351
  118. package/src/enrolled-machine-store.ts +3 -3
  119. package/src/formation-token.ts +63 -0
  120. package/src/index.ts +30 -17
  121. package/src/joined-strand-store.ts +69 -24
  122. package/src/link-budget.ts +9 -7
  123. package/src/member-redemption.ts +20 -13
  124. package/src/membership-view.ts +144 -0
  125. package/src/owner-grant-protocol.ts +33 -18
  126. package/src/peer-addr-book.ts +4 -3
  127. package/src/peer-join-backfill.ts +971 -761
  128. package/src/pending-join-runner.ts +76 -53
  129. package/src/seed-bootstrap.ts +72 -67
  130. package/src/strand-database.ts +352 -322
  131. package/src/strand-first-sync-gate.ts +334 -134
  132. package/src/strand-formation-deadlines.ts +227 -227
  133. package/src/strand-formation-manager.ts +731 -725
  134. package/src/strand-formation-protocol.ts +122 -24
  135. package/src/strand-instance-manager.ts +2092 -2026
  136. package/src/strand-membership-reconciler.ts +33 -25
  137. package/src/strand-membership-writer.ts +2093 -2019
  138. package/src/strand-move.ts +5 -2
  139. package/src/strand-revocation-enforcer.ts +740 -654
  140. package/src/strand-solicitation.ts +61 -34
  141. package/src/strand-watcher.ts +175 -56
  142. package/src/table-change-watch.ts +285 -0
  143. package/src/timeout-scheduler.ts +29 -28
  144. package/src/types.ts +89 -51
@@ -6,10 +6,11 @@
6
6
  * is described in `docs/strands.md` → "Joining while the inviter is offline".
7
7
  *
8
8
  * `CadreNode` owns every side effect and hands this module plain functions
9
- * ({@link PendingJoinRunnerDeps}): read the joins, run one attempt (its own `formStrand`), write
10
- * an outcome, say whether this machine is an owner, and the clock. This module owns the policy:
11
- * when to attempt, how to read an attempt's failure, and how to settle an outcome write that
12
- * another machine's write beat.
9
+ * ({@link PendingJoinRunnerDeps}): read the joins, hear a change to the tables they are read
10
+ * from, run one attempt (its own `formStrand`), write an outcome, say whether this machine is an
11
+ * owner, and the clock. This module owns the policy: when to read the joins, when to attempt,
12
+ * how to read an attempt's failure, and how to settle an outcome write that another machine's
13
+ * write beat.
13
14
  *
14
15
  * Only owner machines run it, because every pending-join write is owner-signed. Letting a
15
16
  * machine without an owner key finish a join is `blocked/decide-non-owner-machine-completes-a-pending-join`;
@@ -30,12 +31,28 @@ import { PendingJoinChangedError, type JoinRequestFields, type PendingJoinFields
30
31
  import { FormationPostApprovalError, FormationRejectedError, FormationUnreachableError } from './strand-formation-rejection.js';
31
32
  import { formationDeadlines } from './strand-formation-deadlines.js';
32
33
  import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
34
+ import { scheduleRuns, type TableChangeWatch, type TableChangeWatchOptions } from './table-change-watch.js';
33
35
  import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
34
36
 
35
37
  const log = debug('sereus:cadre:pending-join');
36
38
 
37
- /** How often an owner machine re-reads the party's pending joins (it also reads once right after start). */
38
- export const PENDING_JOIN_POLL_MS = 30_000;
39
+ /**
40
+ * The longest an owner machine goes without re-reading the party's pending joins when no change
41
+ * to their tables is heard. It covers a change that arrives without a storage event, and the
42
+ * time-driven removal of a finished join, which lands up to this much late.
43
+ *
44
+ * NOTE: on a cadre larger than `CONTROL_REPLICATION_BREADTH` (16), a machine outside a join
45
+ * table's cohort hears no change event, so a dismissal or another machine's outcome reaches it
46
+ * up to this much late. The worst effect is one more attempt on a dismissed join, whose outcome
47
+ * write then finds the request gone, or a `token-spent` answer whose re-read adopts the other
48
+ * machine's outcome. A 30 s pass while any join is pending was declined: a join can stay pending
49
+ * for {@link MAX_PENDING_JOIN_MS}, and those reads are what this backstop removes. Revisit if a
50
+ * cadre above 16 machines is seen attempting dismissed joins.
51
+ */
52
+ export const PENDING_JOIN_BACKSTOP_MS = 10 * 60_000;
53
+
54
+ /** How soon a pass runs again after one failed, or after an outcome write gave up to another machine's writes. */
55
+ const PENDING_JOIN_PASS_RETRY_MS = 30_000;
39
56
 
40
57
  /** Ceiling on the wait between two attempts of one join on one machine, before jitter. */
41
58
  export const PENDING_JOIN_MAX_BACKOFF_MS = 10 * 60_000;
@@ -52,7 +69,7 @@ const MAX_BACKGROUND_ATTEMPTS = 2;
52
69
  /** Each retry delay is moved by up to this fraction either way, so machines that failed together do not retry together. */
53
70
  const RETRY_JITTER = 0.2;
54
71
 
55
- /** Re-read-and-rewrite rounds after another machine's write won, before the next pass decides instead. */
72
+ /** Re-read-and-rewrite rounds after another machine's write won, before a pass {@link PENDING_JOIN_PASS_RETRY_MS} later decides instead. */
56
73
  const MAX_OUTCOME_WRITE_ROUNDS = 3;
57
74
 
58
75
  type AttemptError = NonNullable<PendingJoinStatus['lastError']>;
@@ -68,6 +85,10 @@ export interface PendingJoinRunnerDeps {
68
85
  /** Whether this machine may write pending joins now. Asked every pass. */
69
86
  isOwner(): Promise<boolean>;
70
87
  readRows(): Promise<PendingJoin[]>;
88
+ /** Subscribe `onChange` to the tables a pass reads. Absent: passes come from `start`, `kick`, retries and the backstop only (tests). */
89
+ watchChanges?(onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch;
90
+ /** Run a pass at least this long after the last one settled; default {@link PENDING_JOIN_BACKSTOP_MS}. */
91
+ backstopMs?: number;
71
92
  readRow(id: string): Promise<PendingJoin | null>;
72
93
  /** One formation attempt for the request's invitation and disclosure (`CadreNode.formStrand`). */
73
94
  attempt(join: PendingJoin): Promise<FormStrandResult>;
@@ -77,7 +98,7 @@ export interface PendingJoinRunnerDeps {
77
98
  rewrite(expectedStampId: string, next: PendingJoinFields): Promise<PendingJoin>;
78
99
  /** `ControlDatabase.deletePendingJoin`, owner-signed. */
79
100
  remove(id: string): Promise<boolean>;
80
- /** Whether this machine has no control connection now, so a write it commits reaches no other machine. */
101
+ /** Whether this machine reaches no sibling now (`CadreNode.hasReachableControlSibling`), so a write it commits reaches no other machine. */
81
102
  isAlone(): boolean;
82
103
  /** The sApp the request's invitation names. */
83
104
  sAppIdOf(join: PendingJoin): string;
@@ -231,7 +252,7 @@ function stableFraction(key: string): number {
231
252
 
232
253
  export class PendingJoinRunner {
233
254
  private readonly tracked = new Map<string, TrackedJoin>();
234
- /** Joins whose latest write by this machine committed with no control connection: id → the request stamp written. */
255
+ /** Joins whose latest write by this machine committed with no sibling reachable: id → the request stamp written. */
235
256
  private readonly writtenAlone = new Map<string, string>();
236
257
  private readonly dueQueue: TrackedJoin[] = [];
237
258
  private readonly scheduler: TimeoutScheduler;
@@ -242,9 +263,8 @@ export class PendingJoinRunner {
242
263
  /** How long a `token-spent` answer waits before its confirming attempt: long enough for a sibling's `joined` write to replicate. */
243
264
  private readonly spentConfirmDelayMs: number;
244
265
  private running = false;
245
- private pollTimer: unknown;
246
- private passInFlight: Promise<void> | null = null;
247
- private passAgain = false;
266
+ /** Every pass runs on it, one at a time; present between {@link start} and {@link stop}. */
267
+ private watch: TableChangeWatch | null = null;
248
268
  private backgroundAttempts = 0;
249
269
 
250
270
  constructor(private readonly deps: PendingJoinRunnerDeps) {
@@ -256,11 +276,21 @@ export class PendingJoinRunner {
256
276
  this.spentConfirmDelayMs = 2 * deadlines.sessionMs;
257
277
  }
258
278
 
259
- /** Start passes: one at once, then every {@link PENDING_JOIN_POLL_MS}. */
279
+ /**
280
+ * Start passes: one at once, then one whenever a table a pass reads changes, and at the latest
281
+ * {@link PENDING_JOIN_BACKSTOP_MS} after the last. Throws when the change subscription cannot
282
+ * be made.
283
+ *
284
+ * NOTE: a pass from before a {@link stop} that is still in flight runs alongside the new
285
+ * watch's passes, since each watch keeps only its own runs to one at a time. `CadreNode` builds
286
+ * a fresh runner per start, so nothing restarts one; if something comes to, make that pass
287
+ * check which start it belongs to.
288
+ */
260
289
  start(): void {
261
290
  if (this.running) return;
291
+ this.watch = this.subscribe();
262
292
  this.running = true;
263
- this.schedulePass(0);
293
+ this.watch.trigger(0);
264
294
  }
265
295
 
266
296
  /**
@@ -274,18 +304,21 @@ export class PendingJoinRunner {
274
304
  */
275
305
  stop(): void {
276
306
  this.running = false;
277
- this.clearPollTimer();
307
+ // A pass already in flight finishes; `running` keeps it from scheduling anything further.
308
+ this.watch?.dispose();
309
+ this.watch = null;
278
310
  for (const entry of this.tracked.values()) this.cancelTimer(entry);
279
311
  this.tracked.clear();
280
312
  this.dueQueue.length = 0;
281
313
  this.writtenAlone.clear();
282
314
  }
283
315
 
284
- /** Run a pass now, for a change that may make one useful (this machine just became an owner). */
316
+ /**
317
+ * Run a pass now, for a change the watched tables do not show (this machine's owner signer was
318
+ * just wired). A pass in flight is followed by one more.
319
+ */
285
320
  kick(): void {
286
- if (!this.running) return;
287
- this.clearPollTimer();
288
- void this.pass();
321
+ this.watch?.trigger(0);
289
322
  }
290
323
 
291
324
  /**
@@ -327,8 +360,8 @@ export class PendingJoinRunner {
327
360
 
328
361
  /**
329
362
  * Re-write, with identical content under a fresh stamp, each join this machine last wrote while
330
- * it had no control connection, so the other machines receive it. Called on the control
331
- * connection growth edge. A join someone wrote since is left alone.
363
+ * no sibling was reachable, so the other machines receive it. Called on the control cohort's
364
+ * growth edge, when a sibling becomes reachable. A join someone wrote since is left alone.
332
365
  *
333
366
  * NOTE: in memory only, so a join written alone by a process that stopped before it reconnected
334
367
  * reaches the party only with that join's next write. A sweep of every join this machine wrote,
@@ -359,41 +392,29 @@ export class PendingJoinRunner {
359
392
  }
360
393
  }
361
394
 
362
- private schedulePass(delayMs: number): void {
363
- if (!this.running) return;
364
- this.clearPollTimer();
365
- this.pollTimer = this.scheduler.setTimeout(() => {
366
- this.pollTimer = undefined;
367
- void this.pass();
368
- }, delayMs);
395
+ /** The watch every pass runs on: the deps' change subscription, or a timer-only one. */
396
+ private subscribe(): TableChangeWatch {
397
+ const onChange = (): Promise<void> => this.pass();
398
+ const options: TableChangeWatchOptions = {
399
+ label: 'pending-join',
400
+ backstopMs: this.deps.backstopMs ?? PENDING_JOIN_BACKSTOP_MS,
401
+ scheduler: this.scheduler,
402
+ };
403
+ return this.deps.watchChanges?.(onChange, options) ?? scheduleRuns(onChange, options);
369
404
  }
370
405
 
371
- private clearPollTimer(): void {
372
- if (this.pollTimer !== undefined) {
373
- this.scheduler.clearTimeout(this.pollTimer);
374
- this.pollTimer = undefined;
406
+ /** One pass; a failed one runs again {@link PENDING_JOIN_PASS_RETRY_MS} later. */
407
+ private async pass(): Promise<void> {
408
+ try {
409
+ await this.runPass();
410
+ } catch (error) {
411
+ log('pending-join pass failed; retried in 30 s: %s', errorText(error));
412
+ this.retryPass();
375
413
  }
376
414
  }
377
415
 
378
- /** One pass at a time; a kick during a pass runs one more after it. */
379
- private pass(): Promise<void> {
380
- if (this.passInFlight) {
381
- this.passAgain = true;
382
- return this.passInFlight;
383
- }
384
- const run = this.runPass()
385
- .catch((error: unknown) => log('pending-join pass failed; the next one retries: %s', errorText(error)))
386
- .finally(() => {
387
- this.passInFlight = null;
388
- if (this.passAgain && this.running) {
389
- this.passAgain = false;
390
- void this.pass();
391
- return;
392
- }
393
- this.schedulePass(PENDING_JOIN_POLL_MS);
394
- });
395
- this.passInFlight = run;
396
- return run;
416
+ private retryPass(): void {
417
+ this.watch?.trigger(PENDING_JOIN_PASS_RETRY_MS);
397
418
  }
398
419
 
399
420
  private async runPass(): Promise<void> {
@@ -415,7 +436,8 @@ export class PendingJoinRunner {
415
436
  try {
416
437
  await this.consider(join);
417
438
  } catch (error) {
418
- log('pending join %s: this pass could not handle it; the next one retries: %s', join.Id, errorText(error));
439
+ log('pending join %s: this pass could not handle it; retried in 30 s: %s', join.Id, errorText(error));
440
+ this.retryPass();
419
441
  }
420
442
  }
421
443
  }
@@ -663,7 +685,8 @@ export class PendingJoinRunner {
663
685
  }
664
686
  expected = live!;
665
687
  }
666
- log('pending join %s: another machine kept changing the join; the next pass decides', entry.row.Id);
688
+ log('pending join %s: another machine kept changing the join; a pass in 30 s decides', entry.row.Id);
689
+ this.retryPass();
667
690
  }
668
691
 
669
692
  private settle(join: PendingJoin): void {
@@ -17,6 +17,7 @@ import { relayedRequestBudgetMs } from './link-budget.js';
17
17
  import type {
18
18
  ControlNetworkSeed,
19
19
  SeedPeer,
20
+ SeedDialTarget,
20
21
  SeedMessage,
21
22
  SeedAckMessage,
22
23
  SeedRefusalCode,
@@ -31,10 +32,9 @@ import type {
31
32
  PeerAddressRecord,
32
33
  DeviceTokenRecord,
33
34
  RevocationRow,
34
- RevocationLedgerOpenResult,
35
35
  MachineRemovalResult
36
36
  } from './types.js';
37
- import type { ControlDatabase } from './control-database.js';
37
+ import type { CadrePeerRetouchMode, CadrePeerRetouchOutcome, ControlDatabase } from './control-database.js';
38
38
  import { generateStampId, ownerKeyLabel } from './control-database.js';
39
39
  import { canonicalJson } from './canonical-json.js';
40
40
  import { deviceTokenAddDigest } from './peer-authorization.js';
@@ -117,6 +117,20 @@ export function seedDigest(seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>):
117
117
  return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url') as string;
118
118
  }
119
119
 
120
+ /**
121
+ * The peers `seed` nominates for dialing: its preferred peers (`SeedPeer.preferred`: the machine
122
+ * that minted it and every always-on machine) that have an address and are not `selfPeerId`.
123
+ *
124
+ * Self is dropped because `createSeed` projects every non-revoked CadrePeer row, so a preferred
125
+ * machine applying a seed minted after it joined finds itself in the list, and a dial of self
126
+ * always throws. An undefined `selfPeerId` matches nothing.
127
+ */
128
+ export function seedDialTargets(seed: ControlNetworkSeed, selfPeerId: string | undefined): SeedDialTarget[] {
129
+ return seed.peers
130
+ .filter((peer) => peer.preferred && peer.multiaddrs.length > 0 && peer.peerId !== selfPeerId)
131
+ .map(({ peerId, multiaddrs }) => ({ peerId, multiaddrs }));
132
+ }
133
+
120
134
  /** Add `relayAddr` to the entry for `selfPeerId` in `peers`, when that entry exists and lacks it. */
121
135
  function addSelfRelayAddr(peers: SeedPeer[], selfPeerId: string, relayAddr: string): void {
122
136
  const self = peers.find((peer) => peer.peerId === selfPeerId);
@@ -307,7 +321,8 @@ export interface SeedEventCallbacks {
307
321
  /** Called when a seed is received via the protocol */
308
322
  onSeedReceived?: (partyId: string, peerId: string) => void;
309
323
  /**
310
- * Called when a seed is successfully applied.
324
+ * Called when a seed delivered over the protocol has been verified, merged and
325
+ * acknowledged; the dials of its preferred peers follow, so this waits on no connection.
311
326
  *
312
327
  * `seed` is the applied seed itself: the inbound protocol handler applies it
313
328
  * INSIDE the service, so this callback is the only seam through which a
@@ -553,10 +568,10 @@ export class SeedBootstrapService {
553
568
  /**
554
569
  * Return the configured owner signer, or throw if none is set. The single precondition
555
570
  * gate for every owner-signed write. {@link removePeer} / {@link reauthorizePeer} read the
556
- * row's `StampId` from the DB BEFORE they sign, so they call this up front — otherwise a
557
- * signerless service would either surface the wrong "Control database not initialized"
558
- * error or, worse, silently no-op when the target row is absent (the early
559
- * `stampId === null` return) instead of rejecting.
571
+ * row from the DB BEFORE they sign, so they call this up front — otherwise a signerless
572
+ * service would either surface the wrong "Control database not initialized" error or,
573
+ * worse, silently no-op when the target row is absent (their early absent return) instead
574
+ * of rejecting.
560
575
  */
561
576
  private requireOwnerSigner(): OwnerSigner {
562
577
  if (!this.config.ownerSigner) {
@@ -697,35 +712,47 @@ export class SeedBootstrapService {
697
712
  }
698
713
 
699
714
  /**
700
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
701
- * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
702
- * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
703
- * a membership row that committed local-only (its block's cluster ≤1 at insert) is
704
- * pushed to the cohort once it grows.
715
+ * Owner "re-touch" of an existing `CadrePeer` membership row, in one of two modes
716
+ * ({@link CadrePeerRetouchMode}): `'rebroadcast'` bumps `UpdatedAt` and re-vouches the row so
717
+ * it is re-emitted as a fresh, broadcasting transaction — the write-while-alone re-replication
718
+ * primitive (`control-write-ensure-replicated`): a membership row that committed local-only
719
+ * (its block's cluster ≤1 at insert) is pushed to the cohort once it grows — and writes only
720
+ * while the machine has not self-published; `'revouch'` rebinds the voucher to this node's
721
+ * owner key, leaving `UpdatedAt` alone, whether or not the machine has self-published
722
+ * (`CadreNode.removeOwner`).
705
723
  *
706
- * The stamp read, the voucher signature, the write lock and the membership notify are
724
+ * The row read, the mode's decision, the stamp bump, the voucher signature, the write lock,
725
+ * the lost-update retry and the membership notify are
707
726
  * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
708
727
  * `VouchOwner` to this node's owner key. What stays here is the owner-signer precondition.
709
728
  *
710
- * A no-op (no throw, no notify) when the row does not exist.
711
- *
712
729
  * @param peerId - the membership row to re-touch.
713
- * @param updatedAt - the strictly-increasing freshness stamp to write.
730
+ * @param mode - re-broadcast or re-vouch.
731
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}); an absent row, or a
732
+ * self-published one in `'rebroadcast'` mode, writes nothing and does not throw.
714
733
  * @throws if no owner signer is configured (a non-owner cannot
715
734
  * re-sign another peer's row) or the control database is not initialized.
716
735
  */
717
- async reauthorizePeer(peerId: string, updatedAt: number, sign?: OwnerSign): Promise<void> {
736
+ async reauthorizePeer(peerId: string, mode: CadrePeerRetouchMode, sign?: OwnerSign): Promise<CadrePeerRetouchOutcome> {
718
737
  // Fail fast on a signerless service before any DB read (see removePeer): a non-owner
719
738
  // cannot re-sign the voucher, and must not silently no-op on an absent row.
720
739
  const ownerKey = this.requireOwnerSigner().publicKey;
721
740
  const controlDatabase = this.requireControlDatabase();
722
- const retouched = await this.withOwnerSign(`Re-issue the membership of ${peerId}`, sign, (approved) =>
723
- controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, approved));
724
- if (!retouched) {
725
- log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
726
- return;
741
+ const action = mode === 'revouch' ? `Re-vouch the machine ${peerId}` : `Re-issue the membership of ${peerId}`;
742
+ const outcome = await this.withOwnerSign(action, sign, (approved) =>
743
+ controlDatabase.reauthorizeCadrePeer(peerId, mode, ownerKey, approved));
744
+ switch (outcome) {
745
+ case 'absent':
746
+ log('reauthorizePeer(%s): no CadrePeer row for %s (nothing to re-touch)', mode, peerId);
747
+ break;
748
+ case 'self-published':
749
+ log('reauthorizePeer(%s): %s has self-published; its row is its own to refresh', mode, peerId);
750
+ break;
751
+ case 'retouched':
752
+ log('Peer %s re-authorized (%s)', peerId, mode);
753
+ break;
727
754
  }
728
- log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
755
+ return outcome;
729
756
  }
730
757
 
731
758
  /**
@@ -753,26 +780,6 @@ export class SeedBootstrapService {
753
780
  controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, approved));
754
781
  }
755
782
 
756
- /**
757
- * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
758
- * never-written block that the storage layer re-consults on every read.
759
- *
760
- * The signature, the insert-if-absent guard and the `'already-open'` mapping are
761
- * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-signer
762
- * precondition.
763
- *
764
- * @throws if no owner signer is configured or the control database is not
765
- * initialized.
766
- */
767
- async openRevocationLedger(sign?: OwnerSign): Promise<RevocationLedgerOpenResult> {
768
- // Fail fast on a signerless service before any DB work (see removePeer): a
769
- // non-owner cannot sign the marker's append digest.
770
- const ownerKey = this.requireOwnerSigner().publicKey;
771
- const controlDatabase = this.requireControlDatabase();
772
- return await this.withOwnerSign('Open the cadre\'s removal ledger', sign, (approved) =>
773
- controlDatabase.openRevocationLedger(ownerKey, approved));
774
- }
775
-
776
783
  /**
777
784
  * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
778
785
  * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
@@ -997,8 +1004,11 @@ export class SeedBootstrapService {
997
1004
  * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
998
1005
  * reconcile pass until the control database has siblings.
999
1006
  *
1000
- * The inbound handler runs this AFTER it has acked and closed the stream
1001
- * ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
1007
+ * The inbound handler runs this AFTER it has acked, closed the stream and reported the seed
1008
+ * applied ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
1009
+ * The node may stop while these dials are in flight (cadre-cli restarts a claimed node on
1010
+ * `seed:applied`): the node's stop aborts the dial in flight and every later one is refused
1011
+ * at once (`dialAddr`), and each is counted as failed here.
1002
1012
  */
1003
1013
  private async dialPreferredPeers(seed: ControlNetworkSeed): Promise<PreferredDialCounts> {
1004
1014
  const node = this.libp2pNode;
@@ -1006,23 +1016,16 @@ export class SeedBootstrapService {
1006
1016
  log('Seed service shut down before its dials; none attempted');
1007
1017
  return { preferredDialsAttempted: 0, preferredDialsFailed: 0 };
1008
1018
  }
1009
- // `createSeed` projects every non-revoked CadrePeer row, so a preferred machine applying
1010
- // a seed minted after it joined finds ITSELF in the list. Dialing self always throws,
1011
- // which would report a healthy node as "seeded but stranded".
1012
- // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
1013
- // and an undefined self simply matches nothing.
1019
+ // Self is not dialed: dialing self always throws, which would report a healthy node as
1020
+ // "seeded but stranded". Optional-chained: partial libp2p handles (unit-test doubles)
1021
+ // omit `peerId`.
1014
1022
  // Every one of a peer's addresses is a candidate, each on its own time limit
1015
1023
  // (`dialPeerAddrs`), so a peer whose first address never answers neither
1016
1024
  // stalls seed application nor goes undialed at its other addresses. The seed's
1017
1025
  // addresses carry no peer id; the dial binds each to the peer's.
1018
- const selfPeerId = node.peerId?.toString();
1019
- let preferredDialsAttempted = 0;
1026
+ const targets = seedDialTargets(seed, node.peerId?.toString());
1020
1027
  let preferredDialsFailed = 0;
1021
- for (const peer of seed.peers.filter(p => p.preferred)) {
1022
- if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
1023
- continue;
1024
- }
1025
- preferredDialsAttempted++;
1028
+ for (const peer of targets) {
1026
1029
  try {
1027
1030
  const addrs = parseDialAddrs(peer.multiaddrs);
1028
1031
 
@@ -1041,8 +1044,8 @@ export class SeedBootstrapService {
1041
1044
  }
1042
1045
  }
1043
1046
 
1044
- log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, preferredDialsAttempted);
1045
- return { preferredDialsAttempted, preferredDialsFailed };
1047
+ log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, targets.length);
1048
+ return { preferredDialsAttempted: targets.length, preferredDialsFailed };
1046
1049
  }
1047
1050
 
1048
1051
  /**
@@ -1317,14 +1320,16 @@ export class SeedBootstrapService {
1317
1320
 
1318
1321
  /**
1319
1322
  * Read one inbound seed frame, verify and merge it, write the ack and close the
1320
- * stream, then dial the seed's preferred peers.
1323
+ * stream, report the seed applied, then dial the seed's preferred peers.
1321
1324
  *
1322
- * The ack and the close come BEFORE those dials: an unreachable peer can hold
1323
- * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1324
- * link time and does not belong inside the sender's delivery deadline. The close is
1325
- * what releases the sender, which reads the ack to end-of-stream. The stream stays
1326
- * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1327
- * still bounds concurrent dial phases.
1325
+ * The ack, the close and `onSeedApplied` come BEFORE those dials: an unreachable peer
1326
+ * can hold a dial for `dialBudget.totalMs` (86 s at the default declared link), which is
1327
+ * not link time and does not belong inside the sender's delivery deadline, nor ahead of
1328
+ * a claimed node's restart (which stops this node, so its dials would be wasted). The
1329
+ * close is what releases the sender, which reads the ack to end-of-stream, and it is
1330
+ * awaited before `onSeedApplied`, so the ack is flushed before anything the callback
1331
+ * starts. The stream stays counted in {@link activeStreams} through the dials, so
1332
+ * {@link maxConcurrentSeeds} still bounds concurrent dial phases.
1328
1333
  *
1329
1334
  * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1330
1335
  * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
@@ -1361,8 +1366,8 @@ export class SeedBootstrapService {
1361
1366
  );
1362
1367
 
1363
1368
  if (merged.success) {
1364
- await this.dialPreferredPeers(seed);
1365
1369
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1370
+ await this.dialPreferredPeers(seed);
1366
1371
  } else {
1367
1372
  this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
1368
1373
  }