@serfab/cadre-core 1.4.0 → 1.6.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 (63) hide show
  1. package/dist/cadre-node.d.ts +38 -15
  2. package/dist/cadre-node.js +81 -29
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-database.d.ts +24 -10
  5. package/dist/control-database.js +48 -16
  6. package/dist/control-database.js.map +1 -1
  7. package/dist/control-read-retry.d.ts +12 -0
  8. package/dist/control-read-retry.js +12 -0
  9. package/dist/control-read-retry.js.map +1 -1
  10. package/dist/control-write-retry.d.ts +5 -4
  11. package/dist/control-write-retry.js +24 -7
  12. package/dist/control-write-retry.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/link-budget.d.ts +224 -0
  17. package/dist/link-budget.js +239 -0
  18. package/dist/link-budget.js.map +1 -0
  19. package/dist/peer-dial.d.ts +36 -17
  20. package/dist/peer-dial.js +37 -17
  21. package/dist/peer-dial.js.map +1 -1
  22. package/dist/peer-join-backfill.d.ts +58 -4
  23. package/dist/peer-join-backfill.js +166 -20
  24. package/dist/peer-join-backfill.js.map +1 -1
  25. package/dist/relay-reservation.d.ts +59 -11
  26. package/dist/relay-reservation.js +169 -43
  27. package/dist/relay-reservation.js.map +1 -1
  28. package/dist/seed-bootstrap.js +2 -1
  29. package/dist/seed-bootstrap.js.map +1 -1
  30. package/dist/strand-first-sync-gate.d.ts +105 -22
  31. package/dist/strand-first-sync-gate.js +121 -27
  32. package/dist/strand-first-sync-gate.js.map +1 -1
  33. package/dist/strand-instance-manager.d.ts +27 -2
  34. package/dist/strand-instance-manager.js +60 -13
  35. package/dist/strand-instance-manager.js.map +1 -1
  36. package/dist/strand-membership-reconciler.d.ts +200 -45
  37. package/dist/strand-membership-reconciler.js +302 -86
  38. package/dist/strand-membership-reconciler.js.map +1 -1
  39. package/dist/strand-membership-writer.d.ts +3 -2
  40. package/dist/strand-membership-writer.js +67 -90
  41. package/dist/strand-membership-writer.js.map +1 -1
  42. package/dist/strand-network-config.d.ts +2 -2
  43. package/dist/strand-network-config.js +2 -2
  44. package/dist/types.d.ts +105 -9
  45. package/dist/types.js +20 -4
  46. package/dist/types.js.map +1 -1
  47. package/package.json +8 -8
  48. package/src/cadre-node.ts +7449 -7397
  49. package/src/control-database.ts +48 -16
  50. package/src/control-read-retry.ts +214 -202
  51. package/src/control-write-retry.ts +24 -7
  52. package/src/index.ts +582 -563
  53. package/src/link-budget.ts +264 -0
  54. package/src/peer-dial.ts +39 -17
  55. package/src/peer-join-backfill.ts +648 -478
  56. package/src/relay-reservation.ts +1119 -951
  57. package/src/seed-bootstrap.ts +2 -1
  58. package/src/strand-first-sync-gate.ts +352 -258
  59. package/src/strand-instance-manager.ts +1520 -1458
  60. package/src/strand-membership-reconciler.ts +334 -92
  61. package/src/strand-membership-writer.ts +1962 -1987
  62. package/src/strand-network-config.ts +2 -2
  63. package/src/types.ts +104 -8
@@ -26,39 +26,93 @@
26
26
  * that finds no member row AND no staged invitation is not an unfinished join but a wait
27
27
  * for someone to admit this party — nothing this machine can do faster — so it re-arms on
28
28
  * the flat poll interval and resets the ladder. The loop stops on the done state.
29
- * - **Done state**: the party's member row is visible locally AND this machine's own
30
- * binding is in place — the loop stops for good. A LATER revocation is the enforcer's
31
- * business, not this loop's; a resume rebuilds the reconciler and re-verifies from
32
- * scratch (every write is idempotent, so re-running is free).
29
+ * - **Done state**: the party's member row is visible locally, this machine's own binding
30
+ * is in place, AND no invitation is still staged — the loop stops. A LATER revocation is
31
+ * the enforcer's business, not this loop's; a resume rebuilds the reconciler and
32
+ * re-verifies from scratch (every write is idempotent, so re-running is free), and a
33
+ * fresh invitation staged on a done loop re-arms it in place ({@link rearm}).
33
34
  *
34
35
  * ## One pass
35
36
  *
36
- * 1. **Self-revocation check.** If the enforcer flags this node's own party as revoked,
37
- * stop rather than fight — re-admission arrives (if ever) via a fresh formation.
37
+ * 1. **Self-revocation check.** If the enforcer flags this node's own party as revoked and
38
+ * no invitation is staged, stop rather than fight — re-admission arrives (if ever) via
39
+ * a fresh formation, which re-arms the loop. With an invitation staged the loop reports
40
+ * the dead end (below) and carries on: the redemption is attempted like any other, and a
41
+ * manager's re-admission lets a later pass finish it.
38
42
  * 2. **Ensure membership.** Member row visible → done with this step; if a staged
39
43
  * invitation is still unspent, BURN it ({@link burnInvite} — the `ConsumedInvite` row
40
- * alone) so the bearer credential cannot be spent by anyone else, ignoring burn
41
- * failures beyond a log. Member row absent + staged invitation → {@link consumeInvite}
44
+ * alone) so the bearer credential cannot be spent by anyone else. A burn refused by the
45
+ * cohort keeps the invitation staged for the next pass (it is the same write gate a
46
+ * redemption faces, and the local member row may be stale — see "Re-arming"); a dead
47
+ * invitation is dropped. Member row absent + staged invitation → {@link consumeInvite}
42
48
  * under the party's own key, retried across passes until the `Invite` row has
43
49
  * replicated here and the write commits. Absent with no invitation (founder rows not
44
50
  * yet synced, or a machine whose party never joined) → keep waiting, escalating to one
45
51
  * visible warning after {@link IDLE_PASSES_BEFORE_ESCALATION} passes, never throwing.
46
52
  * 3. **Ensure the binding.** {@link registerMemberPeer} (insert-if-absent, restart-safe)
47
53
  * with this node's own strand transport peer id — only after step 2 sees the member
48
- * row locally (its deferred `MemberExists` reads the live local table).
54
+ * row locally (its deferred `MemberExists` reads the live local table). Done only once
55
+ * the binding is in place AND nothing is staged; a binding written while a burn keeps
56
+ * being refused leaves the loop live to settle the invitation later.
49
57
  *
50
- * ## Terminal states (stop without done)
58
+ * ## Stops, and re-arming
59
+ *
60
+ * Two kinds of stop. A RE-ARMABLE stop is one a fresh invitation should reopen:
61
+ *
62
+ * - **Done** (above).
63
+ * - **Self-revoked with nothing staged** (step 1 above).
64
+ *
65
+ * A PERMANENT stop is one a fresh invitation changes nothing about — {@link rearm} is a
66
+ * no-op after it:
51
67
  *
52
68
  * - **Sealed strand**: `consumeInvite` rejected by `ConsumedInvite.NotSealed` — nobody
53
69
  * can ever be admitted, so retrying forever is noise. Terminal-with-log.
54
- * - **Self-revoked** (step 1 above).
55
70
  * - **Undecodable party key**: nothing can be signed; loud log, stop.
71
+ * - **The public {@link stop}**: `StrandInstanceManager.releaseRuntime` and
72
+ * `clearOwnMemberPeerBinding` both call it and mean it — the latter stops the loop and
73
+ * awaits {@link settle} precisely so no pass can re-register the binding it is about to
74
+ * delete, and a re-arm that restarted the loop would undo that.
75
+ *
76
+ * `CadreNode.adoptFormationMembershipInvite` stages every fresh invitation and notifies
77
+ * the instance manager, which calls {@link rearm}: a done loop reopens and runs one pass
78
+ * at once; a running loop is merely kicked. This is what lets a party that was REMOVED
79
+ * from the strand, and then handed a new invitation, actually attempt it — the loop
80
+ * finished during its first join, long before the removal.
81
+ *
82
+ * On a removed party the local replica is usually stale: the cohort cut this machine off
83
+ * at about the moment the removal was written, so `isStrandMember` still answers true and
84
+ * a re-armed pass lands in the already-member arm. That is why the burn arm keeps a
85
+ * refused invitation staged and why the done state requires nothing staged — otherwise
86
+ * the fresh credential would be dropped on the first refused burn and the loop would latch
87
+ * done again, with the only trace a debug line.
56
88
  *
57
89
  * A DEAD invitation that is not terminal for the loop — expired, cancelled, or already
58
90
  * consumed by someone else (`ConsumedInvite`'s primary key) — is dropped with a log and
59
91
  * the loop keeps idling: a fresh formation stages a new invitation, and a manager-side
60
92
  * admission (`addMemberByManager`) seats the member row without one.
61
93
  *
94
+ * ## Reporting a blocked re-join
95
+ *
96
+ * Redeeming (or burning) writes into the strand, and the machines that would carry that
97
+ * write are exactly the ones the remaining members refuse once this party is removed. So
98
+ * the attempt is expected to fail, and the failure is REPORTED rather than left to the
99
+ * ladder: one `console.warn` plus the {@link StrandMembershipReconcilerDeps.onRejoinBlocked}
100
+ * callback (`CadreNode`'s `strand:rejoin-blocked` event), at most once per re-arm cycle.
101
+ * Two triggers:
102
+ *
103
+ * - **Confirmed** — a pass finds this party self-revoked AND an invitation staged. The node
104
+ * knows it was removed and is holding a credential it cannot spend.
105
+ * - **Probable** — {@link UNFINISHED_PASSES_BEFORE_ESCALATION} consecutive attempted passes
106
+ * left the invitation staged. The case that matters most in the field: a removed party
107
+ * that never learned it was removed (the removal did not replicate here before the cut).
108
+ * It is a suspicion, not a verdict — the invitation's `Invite` row may simply not have
109
+ * replicated here yet — and the warning names both causes.
110
+ *
111
+ * The remedy in either case is a remaining manager admitting this party's member key
112
+ * directly (`addMemberByManager`): that lifts the refusal, replication resumes, and the
113
+ * still-running loop sees the member row, burns the leftover credential and finishes by
114
+ * itself.
115
+ *
62
116
  * ## Sharing the database with the app
63
117
  *
64
118
  * The strand `Database` this loop writes to is the one the app holds. Every write here
@@ -97,6 +151,16 @@ const log = debug('sereus:cadre:strand-membership-reconciler');
97
151
  * founder-row replication is visible without the debug namespace enabled.
98
152
  */
99
153
  export const IDLE_PASSES_BEFORE_ESCALATION = 10;
154
+ /**
155
+ * Consecutive passes that ran against a live database and ended with the staged
156
+ * invitation still unsettled — whatever refused it — before the loop reports a probable
157
+ * blocked re-join (see "Reporting a blocked re-join" in the module doc). The same order
158
+ * as {@link IDLE_PASSES_BEFORE_ESCALATION}: about three minutes at the default cadence
159
+ * (the ladder's five doubling rungs, then five passes at the 30 s cap). Counted by outcome
160
+ * rather than by classified failure because a cut-off machine can fail before any write is
161
+ * classified: its membership READ may already throw.
162
+ */
163
+ export const UNFINISHED_PASSES_BEFORE_ESCALATION = 10;
100
164
  /**
101
165
  * First retry delay, ms, for a pass that left the join UNFINISHED — a `consumeInvite`
102
166
  * whose `Strand.Invite` row has not replicated to this machine yet, a cohort briefly
@@ -131,12 +195,14 @@ const SEALED_REJECTION = /CHECK constraint failed: NotSealed\b/;
131
195
  */
132
196
  const DEAD_INVITE_REJECTION = /CHECK constraint failed: (?:NotExpired|NotCancelled)\b|UNIQUE constraint failed: ConsumedInvite\.InviteKey\b/;
133
197
  /**
134
- * Route a `consumeInvite` rejection. Typed checks run before any text check: a busy refusal
135
- * (another transaction was open, so nothing ran) and a half-committed join are recognised by
136
- * their error's type anywhere in the `cause` chain. The busy refusal carries Quereus's own
137
- * refusal as its cause, and a half-committed join's message embeds collection names and the
138
- * underlying failure's text, either of which a text matcher can misread. The sealed and
139
- * dead-invitation checks then read the top-level message.
198
+ * Route a `consumeInvite` or `burnInvite` rejection. Both write the `Strand.ConsumedInvite`
199
+ * row under the same `NotExpired` / `NotCancelled` / `NotSealed` / primary-key constraints
200
+ * (a burn writes that row alone), so one classifier serves both. Typed checks run before any
201
+ * text check: a busy refusal (another transaction was open, so nothing ran) and a
202
+ * half-committed join are recognised by their error's type anywhere in the `cause` chain.
203
+ * The busy refusal carries Quereus's own refusal as its cause, and a half-committed join's
204
+ * message embeds collection names and the underlying failure's text, either of which a text
205
+ * matcher can misread. The sealed and dead-invitation checks then read the top-level message.
140
206
  *
141
207
  * A second loaded copy of `@optimystic/db-core` (or of the plugin) would fail `instanceof`; the
142
208
  * half-commit then falls through the anchored texts to `retry`, and once the saved `ConsumedInvite`
@@ -197,9 +263,19 @@ export class StrandMembershipReconciler {
197
263
  this.tail = Promise.resolve();
198
264
  this.started = false;
199
265
  this.stoppedFlag = false;
266
+ /**
267
+ * Whether the last stop was one {@link rearm} may not undo — see "Stops, and re-arming"
268
+ * in the module doc. Latched by the public {@link stop} and the permanent terminal
269
+ * states, never cleared.
270
+ */
271
+ this.permanentlyStopped = false;
200
272
  this.doneFlag = false;
201
273
  this.idlePasses = 0;
202
274
  this.idleEscalated = false;
275
+ /** Consecutive attempted passes that left the invitation staged — see {@link UNFINISHED_PASSES_BEFORE_ESCALATION}. */
276
+ this.unfinishedPasses = 0;
277
+ /** Whether the blocked re-join was already reported this re-arm cycle (one report, either trigger). */
278
+ this.rejoinBlockedReported = false;
203
279
  /**
204
280
  * Whether the pass that just ran found nothing to act on — no member row and no staged
205
281
  * invitation. That is a wait to be admitted, not an unfinished join, so it re-arms on the
@@ -215,11 +291,14 @@ export class StrandMembershipReconciler {
215
291
  this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
216
292
  this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_REVOCATION_POLL_INTERVAL_MS;
217
293
  }
218
- /** True once member row + own binding were both confirmed and the loop stopped. */
294
+ /**
295
+ * True once member row + own binding were both confirmed with nothing staged and the
296
+ * loop stopped. Cleared again by {@link rearm}.
297
+ */
219
298
  get done() {
220
299
  return this.doneFlag;
221
300
  }
222
- /** True once the loop has stopped — done, terminal, or externally stopped. */
301
+ /** True while the loop is stopped — done, terminal, or externally stopped. */
223
302
  get stopped() {
224
303
  return this.stoppedFlag;
225
304
  }
@@ -245,13 +324,23 @@ export class StrandMembershipReconciler {
245
324
  async settle() {
246
325
  await this.tail;
247
326
  }
248
- /** Disarm the retry timer; a pass already in flight completes but writes idempotently. */
327
+ /**
328
+ * Disarm the retry timer, for good: a later {@link rearm} is refused. A pass already in
329
+ * flight completes but writes idempotently — {@link settle} is what waits it out.
330
+ */
249
331
  stop() {
250
- if (this.stoppedFlag)
251
- return;
252
- this.stoppedFlag = true;
253
- this.clearTimer();
254
- log('[%s] membership reconciler stopped%s', this.deps.label, this.doneFlag ? ' (done)' : '');
332
+ this.halt('stopped by the runtime', true);
333
+ }
334
+ /**
335
+ * A fresh invitation was staged: reopen a loop that stopped on a re-armable state (done,
336
+ * or self-revoked with nothing staged) and run one pass at once; merely kick a loop that
337
+ * is still running; do nothing after a permanent stop. Never rejects, and serialized
338
+ * like {@link reconcile}: the reopen happens when the queued pass STARTS, after any pass
339
+ * in flight has settled, so a `done` that pass latches cannot swallow the re-arm — and a
340
+ * public {@link stop} that lands first still wins, because the queued pass re-checks it.
341
+ */
342
+ rearm() {
343
+ return this.enqueue(() => this.reopen() ? this.doPass() : Promise.resolve());
255
344
  }
256
345
  /**
257
346
  * Run one pass now. Never rejects. Serialized: concurrent calls chain, so no two
@@ -261,35 +350,64 @@ export class StrandMembershipReconciler {
261
350
  * published — REPLACES the pending timer rather than running alongside it.
262
351
  */
263
352
  reconcile() {
353
+ return this.enqueue(() => this.doPass());
354
+ }
355
+ /** Chain `pass` after every pass already queued; the tail never rejects. */
356
+ enqueue(pass) {
264
357
  // The catch is what keeps the chain alive. `doPass` contains its own failures, but its
265
358
  // `finally` calls into the injected scheduler, and a throw from there would leave `tail`
266
359
  // REJECTED — after which every later `reconcile()` short-circuits on it and the join
267
- // stalls for good, silently. Swallowing it here makes the "never rejects" contract this
268
- // method, {@link settle} and the two `void`-ed call sites all rely on structural rather
269
- // than incidental.
270
- const run = this.tail.then(() => this.doPass()).catch((error) => {
360
+ // stalls for good, silently. Swallowing it here makes the "never rejects" contract
361
+ // {@link reconcile}, {@link rearm}, {@link settle} and the `void`-ed call sites all rely
362
+ // on structural rather than incidental.
363
+ const run = this.tail.then(pass).catch((error) => {
271
364
  log('[%s] reconcile pass threw outside its own handler — the chain continues: %o', this.deps.label, error);
272
365
  });
273
366
  this.tail = run;
274
367
  return run;
275
368
  }
369
+ /**
370
+ * The re-arm itself, run at the head of the queued pass: `false` refuses the pass (a
371
+ * permanent stop), a running loop is left as it is, and a re-armable stop is reopened
372
+ * with the idle and unfinished counters, both escalation latches and the retry ladder
373
+ * reset — a fresh invitation is a fresh cycle.
374
+ */
375
+ reopen() {
376
+ if (this.permanentlyStopped) {
377
+ log('[%s] re-arm refused — the loop is stopped for good', this.deps.label);
378
+ return false;
379
+ }
380
+ if (!this.stoppedFlag) {
381
+ // NOTE: a running loop keeps its counters and its once-per-cycle report latch, so a
382
+ // party whose loop already reported and is then re-formed AGAIN gets no second report
383
+ // for the replacement invitation. If that second report is ever wanted, key the latch
384
+ // on the staged invitation's key rather than on the re-arm.
385
+ log('[%s] fresh invitation staged on a running loop — kicking a pass', this.deps.label);
386
+ return true;
387
+ }
388
+ this.stoppedFlag = false;
389
+ this.doneFlag = false;
390
+ this.idlePasses = 0;
391
+ this.idleEscalated = false;
392
+ this.unfinishedPasses = 0;
393
+ this.rejoinBlockedReported = false;
394
+ this.retryDelayMs = undefined;
395
+ log('[%s] membership reconciler re-armed by a fresh invitation', this.deps.label);
396
+ return true;
397
+ }
276
398
  /** One serialized pass; contains every failure (contract: never rejects). */
277
399
  async doPass() {
278
400
  if (this.stoppedFlag || this.doneFlag)
279
401
  return;
280
402
  this.lastPassIdle = false;
281
403
  try {
282
- if (this.deps.isSelfRevoked?.() === true) {
283
- // NOTE: "arrives via a fresh formation" is aspirational, not current behaviour.
284
- // A fresh formation stages a new invitation (`adoptFormationMembershipInvite`)
285
- // but does NOT re-arm this loop — `finish` latches `stoppedFlag` and `start()`
286
- // early-returns on it, so only a strand relaunch (quiesce → resume, or a process
287
- // restart) builds a reconciler that would redeem it. The same latch applies on the
288
- // `done` path below, which is the case a REMOVED party actually hits: it finished
289
- // its first join long before it was removed. Measured end to end in
290
- // `strand-party-removal-via-formation-e2e.integration.ts` (test 2), tracked as
291
- // `backlog/bug-removed-party-cannot-redeem-its-way-back`.
292
- this.finish('this party is revoked from the strand — re-admission arrives (if ever) via a fresh formation');
404
+ if (this.deps.isSelfRevoked?.() === true && !this.stagedInviteBlockedBySelfRevocation()) {
405
+ // NOTE: the enforcer's flag lags a re-admission by up to one of its polls. If the
406
+ // pass that settled the last staged invitation then failed to write the binding, this
407
+ // stop lands with the binding missing until the next resume rebuilds the loop. A
408
+ // missing binding only mis-credits diversity today; if it ever gates admission, make
409
+ // this stop also require the binding to be in place.
410
+ this.finish('this party is revoked from the strand — re-admission arrives (if ever) via a fresh formation', false);
293
411
  return;
294
412
  }
295
413
  const keyPair = this.resolveKeyPair();
@@ -298,6 +416,21 @@ export class StrandMembershipReconciler {
298
416
  const db = this.deps.getDatabase();
299
417
  if (!db)
300
418
  return; // no live database this instant (quiesce race) — next tick decides
419
+ await this.attempt(db, keyPair);
420
+ }
421
+ catch (error) {
422
+ log('[%s] reconcile pass failed — retrying next tick: %o', this.deps.label, error);
423
+ }
424
+ finally {
425
+ this.scheduleNext();
426
+ }
427
+ }
428
+ /**
429
+ * Steps 2 and 3 against a live database, with the outcome counted toward the probable
430
+ * blocked re-join report whether the attempt returned or threw.
431
+ */
432
+ async attempt(db, keyPair) {
433
+ try {
301
434
  if (!(await this.ensureMembership(db, keyPair)))
302
435
  return;
303
436
  // NOTE: the redemption (`Member` + `ConsumedInvite`) and the binding below are two
@@ -312,11 +445,8 @@ export class StrandMembershipReconciler {
312
445
  // 27 + 18 baseline.
313
446
  await this.ensureBinding(db, keyPair);
314
447
  }
315
- catch (error) {
316
- log('[%s] reconcile pass failed — retrying next tick: %o', this.deps.label, error);
317
- }
318
448
  finally {
319
- this.scheduleNext();
449
+ this.noteAttemptOutcome();
320
450
  }
321
451
  }
322
452
  /**
@@ -378,34 +508,33 @@ export class StrandMembershipReconciler {
378
508
  }, OWN_TRANSACTION);
379
509
  }
380
510
  catch (error) {
381
- this.handleConsumeFailure(error);
511
+ this.handleConsumeFailure(error, invite);
382
512
  return false;
383
513
  }
384
- this.deps.pendingInvite?.clear();
514
+ this.deps.pendingInvite?.clear(invite);
385
515
  log('[%s] redeemed the staged membership invitation — Member row seated under this party\'s key', this.deps.label);
386
516
  return true;
387
517
  }
388
518
  /**
389
- * The already-member arm: spend a still-staged invitation so nobody else can. Burn
390
- * failures (already spent, cancelled, expired — or a racing seal) are logged and
391
- * otherwise ignored, and the stage is cleared either way: with the member row
392
- * present the invitation has no further local use, and keeping a possibly-dead
393
- * credential staged would leave `getPendingMembershipInvite` lying. The one exception
394
- * is a busy refusal (the app had a transaction open): nothing was tried, so the
395
- * invitation stays staged and the next pass burns it.
519
+ * The already-member arm: spend a still-staged invitation so nobody else can. The stage
520
+ * is cleared once the burn lands or the invitation is known dead (already spent,
521
+ * cancelled, expired, or the strand sealed around a party that is still a member) — with
522
+ * the member row present a dead credential has no further local use, and keeping it
523
+ * staged would leave `getPendingMembershipInvite` lying. A refused burn keeps it staged:
524
+ * a busy refusal tried nothing, and a write the cohort refused is the removed-party shape
525
+ * ("Stops, and re-arming" in the module doc), where the local member row is stale and the
526
+ * credential is the one thing a later manager admission lets this loop settle.
396
527
  *
397
- * NOTE: accepted tradeoff — a burn that failed for a TRANSIENT reason is never
398
- * retried, so that bearer credential stays spendable until it expires. Weighed and
399
- * kept because it lands in the same state the strand already documents as normal
400
- * ("removal does not cancel an unspent invitation", docs/strands.md) and retrying
401
- * would mean keeping a dead-or-alive credential staged indefinitely. Revisit if
402
- * unspent invitations ever become a real admission risk — which is when
403
- * `feat-strand-member-allowlist-admission` lands.
528
+ * NOTE: this REPLACES an earlier accepted tradeoff that never retried a burn which failed
529
+ * for a transient reason and dropped the invitation instead. That decision was made when
530
+ * this arm could only ever see an invitation staged before the first join finished, so a
531
+ * dropped credential cost nothing but a spendable bearer token. {@link rearm} makes the
532
+ * arm reachable with a freshly issued invitation the app wants redeemed, and dropping
533
+ * that one on the first refused write is the silent failure this loop exists to avoid.
404
534
  */
405
535
  async burnLeftoverInvite(db, keyPair) {
406
- const pending = this.deps.pendingInvite;
407
- const invite = pending?.get();
408
- if (!pending || !invite)
536
+ const invite = this.deps.pendingInvite?.get();
537
+ if (!invite)
409
538
  return;
410
539
  try {
411
540
  await burnInvite(db, {
@@ -413,23 +542,49 @@ export class StrandMembershipReconciler {
413
542
  invitePrivateKey: invite.invitePrivateKey,
414
543
  memberKey: keyPair.publicKeyB64,
415
544
  }, OWN_TRANSACTION);
416
- log('[%s] burned the leftover invitation (member row already present)', this.deps.label);
417
545
  }
418
546
  catch (error) {
419
- if (isTransactionBusy(error)) {
547
+ this.handleBurnFailure(error, invite);
548
+ return;
549
+ }
550
+ this.deps.pendingInvite?.clear(invite);
551
+ log('[%s] burned the leftover invitation (member row already present)', this.deps.label);
552
+ }
553
+ /**
554
+ * Act on a `burnInvite` rejection as {@link classifyConsumeFailure} routes it. Unlike a
555
+ * redemption, a sealed strand is not terminal here — the member row is present, so this
556
+ * party is a member of the sealed strand and only its leftover credential is dead — and a
557
+ * half-committed burn (one collection, so a report of it means the commit's durability
558
+ * was in doubt) is retried: the next pass either lands it or fails on the row's primary
559
+ * key, which drops it as dead.
560
+ */
561
+ handleBurnFailure(error, invite) {
562
+ const failure = classifyConsumeFailure(error);
563
+ switch (failure.kind) {
564
+ case 'busy':
420
565
  log('[%s] burning the leftover invitation deferred — the app has a transaction open; it stays staged '
421
566
  + 'for the next pass', this.deps.label);
422
567
  return;
423
- }
424
- log('[%s] burning the leftover invitation failed (already spent, cancelled, or expired) — dropping it: %o', this.deps.label, error);
568
+ case 'sealed':
569
+ case 'dead-invite':
570
+ log('[%s] the leftover invitation is dead (already spent, cancelled, expired, or the strand sealed) — '
571
+ + 'dropping it: %s', this.deps.label, errorMessage(error));
572
+ this.deps.pendingInvite?.clear(invite);
573
+ return;
574
+ case 'half-committed':
575
+ case 'retry':
576
+ log('[%s] burning the leftover invitation was refused — it stays staged and is retried next tick '
577
+ + '(the cohort refusing this machine, or a transient write failure): %s', this.deps.label, errorMessage(error));
578
+ return;
425
579
  }
426
- pending.clear();
427
580
  }
428
581
  /**
429
582
  * Step 3 of the pass: write this machine's own `MemberPeer` binding (insert-if-absent)
430
- * and, on success, latch the done state and stop the loop. A missing transport peer id
431
- * (a quiesce racing the pass) defers to the next tick; a write failure — a busy refusal
432
- * included — is contained by the pass's outer catch and retried.
583
+ * and, once it is in place with nothing staged, latch the done state and stop the loop.
584
+ * A missing transport peer id (a quiesce racing the pass) defers to the next tick; a
585
+ * write failure — a busy refusal included — is contained by the pass's outer catch and
586
+ * retried. A binding written while an invitation is still staged (its burn keeps being
587
+ * refused) is not done: the loop stays live so a later pass can settle the credential.
433
588
  */
434
589
  async ensureBinding(db, keyPair) {
435
590
  const peerId = this.deps.getOwnPeerId();
@@ -438,15 +593,19 @@ export class StrandMembershipReconciler {
438
593
  return;
439
594
  }
440
595
  await registerMemberPeer(db, { memberKeyPair: keyPair, peerId }, OWN_TRANSACTION);
596
+ if (this.deps.pendingInvite?.get()) {
597
+ log('[%s] own MemberPeer binding is in place but an invitation is still staged — not done yet', this.deps.label);
598
+ return;
599
+ }
441
600
  this.doneFlag = true;
442
- this.finish('member row and own MemberPeer binding are both in place');
601
+ this.finish('member row and own MemberPeer binding are both in place', false);
443
602
  }
444
603
  /**
445
604
  * Act on a `consumeInvite` rejection as {@link classifyConsumeFailure} routes it: busy → keep
446
605
  * the invitation and retry; half-committed → warn and drop; sealed → terminal; dead
447
606
  * invitation → drop; anything else → retry.
448
607
  */
449
- handleConsumeFailure(error) {
608
+ handleConsumeFailure(error, invite) {
450
609
  const failure = classifyConsumeFailure(error);
451
610
  switch (failure.kind) {
452
611
  case 'busy':
@@ -454,12 +613,12 @@ export class StrandMembershipReconciler {
454
613
  + 'database; retrying next tick', this.deps.label);
455
614
  return;
456
615
  case 'half-committed':
457
- this.reportHalfCommittedJoin(failure, error);
616
+ this.reportHalfCommittedJoin(failure, error, invite);
458
617
  return;
459
618
  case 'sealed':
460
619
  // The staged credential is dead with the seal; drop it so the cache stays honest.
461
- this.deps.pendingInvite?.clear();
462
- this.finish('the strand is sealed — nobody can ever be admitted, so the staged invitation is dead');
620
+ this.deps.pendingInvite?.clear(invite);
621
+ this.finish('the strand is sealed — nobody can ever be admitted, so the staged invitation is dead', true);
463
622
  return;
464
623
  case 'dead-invite':
465
624
  // NOTE: accepted tradeoff — dropping a dead invitation does not mark the pass idle,
@@ -469,14 +628,59 @@ export class StrandMembershipReconciler {
469
628
  // source of dead invitations ever repeats per pass.
470
629
  log('[%s] the staged invitation is dead (expired, cancelled, or consumed elsewhere) — dropping it; '
471
630
  + 'a fresh formation stages a new one: %s', this.deps.label, errorMessage(error));
472
- this.deps.pendingInvite?.clear();
631
+ this.deps.pendingInvite?.clear(invite);
473
632
  return;
474
633
  case 'retry':
475
- log('[%s] consumeInvite failed — retrying next tick (Invite row not yet replicated here, or a transient '
476
- + 'write failure): %s', this.deps.label, errorMessage(error));
634
+ log('[%s] consumeInvite failed — retrying next tick (Invite row not yet replicated here, the cohort '
635
+ + 'refusing this machine, or a transient write failure): %s', this.deps.label, errorMessage(error));
477
636
  return;
478
637
  }
479
638
  }
639
+ /**
640
+ * Count an attempted pass that left the invitation staged (whatever stopped it — a
641
+ * refused or busy write, or a read that threw on a cut-off machine); one that settled
642
+ * it, or finished, ends the streak. At {@link UNFINISHED_PASSES_BEFORE_ESCALATION}
643
+ * report the PROBABLE blocked re-join.
644
+ */
645
+ noteAttemptOutcome() {
646
+ if (this.doneFlag || !this.deps.pendingInvite?.get()) {
647
+ this.unfinishedPasses = 0;
648
+ return;
649
+ }
650
+ this.unfinishedPasses += 1;
651
+ if (this.unfinishedPasses >= UNFINISHED_PASSES_BEFORE_ESCALATION) {
652
+ this.reportRejoinBlocked(`${this.unfinishedPasses} membership reconcile passes have left the staged membership invitation `
653
+ + 'unsettled. Either this party was removed from the strand and the remaining members are refusing its '
654
+ + 'machines, or the invitation\'s Strand.Invite row has not replicated here yet.');
655
+ }
656
+ }
657
+ /**
658
+ * Step 1's CONFIRMED trigger: self-revoked with an invitation staged. Reports (once per
659
+ * re-arm cycle) and answers `true` so the pass carries on — the redemption is attempted
660
+ * and refused like any other write, and a manager's re-admission lets a later pass finish.
661
+ * `false` (nothing staged) means step 1 stops the loop as before.
662
+ */
663
+ stagedInviteBlockedBySelfRevocation() {
664
+ if (!this.deps.pendingInvite?.get())
665
+ return false;
666
+ this.reportRejoinBlocked('this party is revoked from the strand and holds a staged membership invitation it cannot spend: the '
667
+ + 'remaining members refuse this machine\'s strand writes, so the redemption cannot land.');
668
+ return true;
669
+ }
670
+ /**
671
+ * ONE `console.warn` and one callback per re-arm cycle, whichever trigger fires first —
672
+ * see "Reporting a blocked re-join" in the module doc. `cause` is the trigger's own
673
+ * sentence; the remedy and the loop's posture are the same for both.
674
+ */
675
+ reportRejoinBlocked(cause) {
676
+ if (this.rejoinBlockedReported)
677
+ return;
678
+ this.rejoinBlockedReported = true;
679
+ console.warn(`[sereus] strand ${this.deps.label}: ${cause} A fresh invitation cannot re-admit a removed party by itself — `
680
+ + 'a remaining manager must admit this party\'s member key directly (addMemberByManager). The membership '
681
+ + 'loop keeps retrying quietly and completes the join once a Member row for this party appears.');
682
+ this.deps.onRejoinBlocked?.();
683
+ }
480
684
  /**
481
685
  * A redemption only part of which was saved (see "Half-committed join" in the module doc):
482
686
  * ONE visible warning naming both halves, and the staged invitation dropped. The pass is not
@@ -493,7 +697,7 @@ export class StrandMembershipReconciler {
493
697
  * path. Revisit if a `Member`-saved half-commit is ever observed, or when
494
698
  * `blocked/strand-half-committed-join-recovery` is decided.
495
699
  */
496
- reportHalfCommittedJoin(failure, error) {
700
+ reportHalfCommittedJoin(failure, error, invite) {
497
701
  console.warn(`[sereus] strand ${this.deps.label}: redeeming the staged membership invitation was only partly saved. `
498
702
  + `Saved: [${failure.saved.join(', ')}]. Not saved: [${failure.unsaved.join(', ')}]. `
499
703
  + 'The invitation is dropped: a saved ConsumedInvite row spends it, so it cannot be redeemed again. '
@@ -501,7 +705,7 @@ export class StrandMembershipReconciler {
501
705
  + 'cannot become one from this invitation — a manager must admit it directly (addMemberByManager). '
502
706
  + 'The membership loop keeps checking and completes the join once a Member row for this party appears.');
503
707
  log('[%s] half-committed redemption, full error: %o', this.deps.label, error);
504
- this.deps.pendingInvite?.clear();
708
+ this.deps.pendingInvite?.clear(invite);
505
709
  // The idle passes that follow are the wait this warning already explained; the escalation's
506
710
  // "waiting on the founder rows to replicate" would be a second, misleading warning.
507
711
  this.idleEscalated = true;
@@ -514,7 +718,7 @@ export class StrandMembershipReconciler {
514
718
  this.keyPair = strandMemberKeyPair(this.deps.partyMemberPrivateKey);
515
719
  }
516
720
  catch (error) {
517
- this.finish(`the party membership key does not decode — nothing can be signed (${errorMessage(error)})`);
721
+ this.finish(`the party membership key does not decode — nothing can be signed (${errorMessage(error)})`, true);
518
722
  return undefined;
519
723
  }
520
724
  return this.keyPair;
@@ -539,10 +743,22 @@ export class StrandMembershipReconciler {
539
743
  + 'replicate (or on a manager admission). The loop keeps retrying quietly.');
540
744
  }
541
745
  }
542
- /** Stop with a reason — the terminal and done paths' shared exit. */
543
- finish(reason) {
544
- log('[%s] membership reconciliation stopping: %s', this.deps.label, reason);
545
- this.stop();
746
+ /**
747
+ * Stop with a reason — the terminal and done paths' shared exit. `permanent` says whether
748
+ * a fresh invitation may reopen the loop ("Stops, and re-arming" in the module doc).
749
+ */
750
+ finish(reason, permanent) {
751
+ this.halt(`membership reconciliation stopping: ${reason}`, permanent);
752
+ }
753
+ /** Latch the stop and disarm the timer; a permanent latch sticks even on an already-stopped loop. */
754
+ halt(reason, permanent) {
755
+ if (permanent)
756
+ this.permanentlyStopped = true;
757
+ if (this.stoppedFlag)
758
+ return;
759
+ this.stoppedFlag = true;
760
+ this.clearTimer();
761
+ log('[%s] membership reconciler stopped%s: %s%s', this.deps.label, this.doneFlag ? ' (done)' : '', reason, permanent ? '' : ' — a fresh invitation re-arms it');
546
762
  }
547
763
  }
548
764
  //# sourceMappingURL=strand-membership-reconciler.js.map