@serfab/cadre-core 1.3.0 → 1.5.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 (90) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +70 -34
  3. package/dist/cadre-node.js +197 -60
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +24 -10
  6. package/dist/control-database.js +48 -16
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-formation-recorder.d.ts +4 -4
  9. package/dist/control-formation-recorder.js +6 -6
  10. package/dist/control-formation-recorder.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +12 -0
  12. package/dist/control-read-retry.js +12 -0
  13. package/dist/control-read-retry.js.map +1 -1
  14. package/dist/control-write-retry.d.ts +5 -4
  15. package/dist/control-write-retry.js +24 -7
  16. package/dist/control-write-retry.js.map +1 -1
  17. package/dist/index.d.ts +5 -4
  18. package/dist/index.js +10 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/link-budget.d.ts +182 -0
  21. package/dist/link-budget.js +195 -0
  22. package/dist/link-budget.js.map +1 -0
  23. package/dist/membership-connection-gater.d.ts +18 -5
  24. package/dist/membership-connection-gater.js +50 -10
  25. package/dist/membership-connection-gater.js.map +1 -1
  26. package/dist/peer-dial.d.ts +36 -17
  27. package/dist/peer-dial.js +37 -17
  28. package/dist/peer-dial.js.map +1 -1
  29. package/dist/peer-join-backfill.d.ts +58 -4
  30. package/dist/peer-join-backfill.js +165 -20
  31. package/dist/peer-join-backfill.js.map +1 -1
  32. package/dist/relay-reservation.d.ts +59 -11
  33. package/dist/relay-reservation.js +169 -43
  34. package/dist/relay-reservation.js.map +1 -1
  35. package/dist/seed-bootstrap.js +2 -1
  36. package/dist/seed-bootstrap.js.map +1 -1
  37. package/dist/storage-scope.d.ts +50 -12
  38. package/dist/storage-scope.js +85 -12
  39. package/dist/storage-scope.js.map +1 -1
  40. package/dist/strand-first-sync-gate.d.ts +43 -5
  41. package/dist/strand-first-sync-gate.js +55 -7
  42. package/dist/strand-first-sync-gate.js.map +1 -1
  43. package/dist/strand-formation-manager.js +2 -1
  44. package/dist/strand-formation-manager.js.map +1 -1
  45. package/dist/strand-id.d.ts +35 -0
  46. package/dist/strand-id.js +46 -0
  47. package/dist/strand-id.js.map +1 -0
  48. package/dist/strand-instance-manager.d.ts +27 -2
  49. package/dist/strand-instance-manager.js +75 -15
  50. package/dist/strand-instance-manager.js.map +1 -1
  51. package/dist/strand-membership-reconciler.d.ts +200 -45
  52. package/dist/strand-membership-reconciler.js +302 -86
  53. package/dist/strand-membership-reconciler.js.map +1 -1
  54. package/dist/strand-membership-writer.d.ts +3 -2
  55. package/dist/strand-membership-writer.js +67 -90
  56. package/dist/strand-membership-writer.js.map +1 -1
  57. package/dist/strand-network-config.d.ts +3 -3
  58. package/dist/strand-network-config.js +3 -3
  59. package/dist/strand-solicitation.js +2 -1
  60. package/dist/strand-solicitation.js.map +1 -1
  61. package/dist/strand-watcher.d.ts +45 -0
  62. package/dist/strand-watcher.js +74 -13
  63. package/dist/strand-watcher.js.map +1 -1
  64. package/dist/types.d.ts +190 -24
  65. package/dist/types.js +73 -4
  66. package/dist/types.js.map +1 -1
  67. package/package.json +8 -8
  68. package/src/cadre-node.ts +7445 -7302
  69. package/src/control-database.ts +48 -16
  70. package/src/control-formation-recorder.ts +6 -6
  71. package/src/control-read-retry.ts +214 -202
  72. package/src/control-write-retry.ts +24 -7
  73. package/src/index.ts +29 -2
  74. package/src/link-budget.ts +217 -0
  75. package/src/membership-connection-gater.ts +56 -11
  76. package/src/peer-dial.ts +39 -17
  77. package/src/peer-join-backfill.ts +192 -23
  78. package/src/relay-reservation.ts +1119 -951
  79. package/src/seed-bootstrap.ts +2 -1
  80. package/src/storage-scope.ts +163 -78
  81. package/src/strand-first-sync-gate.ts +55 -7
  82. package/src/strand-formation-manager.ts +647 -646
  83. package/src/strand-id.ts +48 -0
  84. package/src/strand-instance-manager.ts +1516 -1440
  85. package/src/strand-membership-reconciler.ts +334 -92
  86. package/src/strand-membership-writer.ts +1962 -1987
  87. package/src/strand-network-config.ts +250 -250
  88. package/src/strand-solicitation.ts +500 -499
  89. package/src/strand-watcher.ts +388 -325
  90. package/src/types.ts +192 -23
@@ -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
@@ -91,6 +145,16 @@ import { type TimeoutScheduler } from './timeout-scheduler.js';
91
145
  * founder-row replication is visible without the debug namespace enabled.
92
146
  */
93
147
  export declare const IDLE_PASSES_BEFORE_ESCALATION = 10;
148
+ /**
149
+ * Consecutive passes that ran against a live database and ended with the staged
150
+ * invitation still unsettled — whatever refused it — before the loop reports a probable
151
+ * blocked re-join (see "Reporting a blocked re-join" in the module doc). The same order
152
+ * as {@link IDLE_PASSES_BEFORE_ESCALATION}: about three minutes at the default cadence
153
+ * (the ladder's five doubling rungs, then five passes at the 30 s cap). Counted by outcome
154
+ * rather than by classified failure because a cut-off machine can fail before any write is
155
+ * classified: its membership READ may already throw.
156
+ */
157
+ export declare const UNFINISHED_PASSES_BEFORE_ESCALATION = 10;
94
158
  /**
95
159
  * First retry delay, ms, for a pass that left the join UNFINISHED — a `consumeInvite`
96
160
  * whose `Strand.Invite` row has not replicated to this machine yet, a cohort briefly
@@ -111,13 +175,13 @@ export interface HalfCommittedJoin {
111
175
  saved: readonly string[];
112
176
  unsaved: readonly string[];
113
177
  }
114
- /** Where {@link classifyConsumeFailure} routes a failed `consumeInvite`. */
178
+ /** Where {@link classifyConsumeFailure} routes a failed `consumeInvite` or `burnInvite`. */
115
179
  export type ConsumeFailure = HalfCommittedJoin
116
180
  /** The app had a transaction open, so nothing was tried — keep the invitation, retry. */
117
181
  | {
118
182
  kind: 'busy';
119
183
  }
120
- /** The strand is sealed — terminal. */
184
+ /** The strand is sealed — terminal for a non-member; a member's leftover invitation is merely dead. */
121
185
  | {
122
186
  kind: 'sealed';
123
187
  }
@@ -125,17 +189,22 @@ export type ConsumeFailure = HalfCommittedJoin
125
189
  | {
126
190
  kind: 'dead-invite';
127
191
  }
128
- /** Anything else — most often the `Invite` row has not replicated here yet — retry. */
192
+ /**
193
+ * Anything else — the `Invite` row has not replicated here yet, or the cohort refused the
194
+ * write (a removed party's machines are denied) — keep the invitation, retry.
195
+ */
129
196
  | {
130
197
  kind: 'retry';
131
198
  };
132
199
  /**
133
- * Route a `consumeInvite` rejection. Typed checks run before any text check: a busy refusal
134
- * (another transaction was open, so nothing ran) and a half-committed join are recognised by
135
- * their error's type anywhere in the `cause` chain. The busy refusal carries Quereus's own
136
- * refusal as its cause, and a half-committed join's message embeds collection names and the
137
- * underlying failure's text, either of which a text matcher can misread. The sealed and
138
- * dead-invitation checks then read the top-level message.
200
+ * Route a `consumeInvite` or `burnInvite` rejection. Both write the `Strand.ConsumedInvite`
201
+ * row under the same `NotExpired` / `NotCancelled` / `NotSealed` / primary-key constraints
202
+ * (a burn writes that row alone), so one classifier serves both. Typed checks run before any
203
+ * text check: a busy refusal (another transaction was open, so nothing ran) and a
204
+ * half-committed join are recognised by their error's type anywhere in the `cause` chain.
205
+ * The busy refusal carries Quereus's own refusal as its cause, and a half-committed join's
206
+ * message embeds collection names and the underlying failure's text, either of which a text
207
+ * matcher can misread. The sealed and dead-invitation checks then read the top-level message.
139
208
  *
140
209
  * A second loaded copy of `@optimystic/db-core` (or of the plugin) would fail `instanceof`; the
141
210
  * half-commit then falls through the anchored texts to `retry`, and once the saved `ConsumedInvite`
@@ -153,8 +222,12 @@ export declare function classifyConsumeFailure(error: unknown): ConsumeFailure;
153
222
  export interface PendingMembershipInviteSource {
154
223
  /** The staged invitation, or `undefined` when none is pending for this strand. */
155
224
  get(): StrandMembershipInvite | undefined;
156
- /** Drop the staged invitation (spent, burned, or dead). */
157
- clear(): void;
225
+ /**
226
+ * Drop `settled` (spent, burned, or dead) — only if it is still the staged one. A
227
+ * re-formation can replace the entry between a pass's {@link get} and this call, and
228
+ * the fresh invitation it staged must survive the older one being settled.
229
+ */
230
+ clear(settled: StrandMembershipInvite): void;
158
231
  }
159
232
  export interface StrandMembershipReconcilerDeps {
160
233
  /** Log tag naming which strand this reconciler serves (the strand id). */
@@ -180,10 +253,17 @@ export interface StrandMembershipReconcilerDeps {
180
253
  pendingInvite?: PendingMembershipInviteSource;
181
254
  /**
182
255
  * Whether the revocation enforcer currently flags THIS node's own party as revoked.
183
- * When it does, the loop stops rather than fight the enforcer — see the module doc.
184
- * Absent (enforcement disarmed) means "not known revoked".
256
+ * When it does and nothing is staged, the loop stops rather than fight the enforcer;
257
+ * with an invitation staged it reports the blocked re-join and keeps trying — see the
258
+ * module doc. Absent (enforcement disarmed) means "not known revoked".
185
259
  */
186
260
  isSelfRevoked?: () => boolean;
261
+ /**
262
+ * Called when the loop reports a blocked re-join (see "Reporting a blocked re-join" in
263
+ * the module doc) — at most once per re-arm cycle, after the `console.warn`. `CadreNode`
264
+ * wires it to its `strand:rejoin-blocked` event.
265
+ */
266
+ onRejoinBlocked?: () => void;
187
267
  /**
188
268
  * Timer seam for the retry ladder; omit for real (unref'd) timeouts. Timeouts rather
189
269
  * than the enforcer's repeating interval because the delay changes per pass and the next
@@ -224,11 +304,21 @@ export declare class StrandMembershipReconciler {
224
304
  private timer;
225
305
  private started;
226
306
  private stoppedFlag;
307
+ /**
308
+ * Whether the last stop was one {@link rearm} may not undo — see "Stops, and re-arming"
309
+ * in the module doc. Latched by the public {@link stop} and the permanent terminal
310
+ * states, never cleared.
311
+ */
312
+ private permanentlyStopped;
227
313
  private doneFlag;
228
314
  /** Lazily decoded party keypair — see {@link StrandMembershipReconcilerDeps.partyMemberPrivateKey}. */
229
315
  private keyPair;
230
316
  private idlePasses;
231
317
  private idleEscalated;
318
+ /** Consecutive attempted passes that left the invitation staged — see {@link UNFINISHED_PASSES_BEFORE_ESCALATION}. */
319
+ private unfinishedPasses;
320
+ /** Whether the blocked re-join was already reported this re-arm cycle (one report, either trigger). */
321
+ private rejoinBlockedReported;
232
322
  /**
233
323
  * Whether the pass that just ran found nothing to act on — no member row and no staged
234
324
  * invitation. That is a wait to be admitted, not an unfinished join, so it re-arms on the
@@ -244,9 +334,12 @@ export declare class StrandMembershipReconciler {
244
334
  /** Current rung of the unfinished-join retry ladder, ms; unset before the first retry. */
245
335
  private retryDelayMs;
246
336
  constructor(deps: StrandMembershipReconcilerDeps, config?: StrandMembershipReconciliationConfig);
247
- /** True once member row + own binding were both confirmed and the loop stopped. */
337
+ /**
338
+ * True once member row + own binding were both confirmed with nothing staged and the
339
+ * loop stopped. Cleared again by {@link rearm}.
340
+ */
248
341
  get done(): boolean;
249
- /** True once the loop has stopped — done, terminal, or externally stopped. */
342
+ /** True while the loop is stopped — done, terminal, or externally stopped. */
250
343
  get stopped(): boolean;
251
344
  /**
252
345
  * Kick an immediate pass (not awaited — bring-up never blocks on it). Every later pass
@@ -262,8 +355,20 @@ export declare class StrandMembershipReconciler {
262
355
  * this after stopping. Never rejects: the pass chain contains every failure.
263
356
  */
264
357
  settle(): Promise<void>;
265
- /** Disarm the retry timer; a pass already in flight completes but writes idempotently. */
358
+ /**
359
+ * Disarm the retry timer, for good: a later {@link rearm} is refused. A pass already in
360
+ * flight completes but writes idempotently — {@link settle} is what waits it out.
361
+ */
266
362
  stop(): void;
363
+ /**
364
+ * A fresh invitation was staged: reopen a loop that stopped on a re-armable state (done,
365
+ * or self-revoked with nothing staged) and run one pass at once; merely kick a loop that
366
+ * is still running; do nothing after a permanent stop. Never rejects, and serialized
367
+ * like {@link reconcile}: the reopen happens when the queued pass STARTS, after any pass
368
+ * in flight has settled, so a `done` that pass latches cannot swallow the re-arm — and a
369
+ * public {@link stop} that lands first still wins, because the queued pass re-checks it.
370
+ */
371
+ rearm(): Promise<void>;
267
372
  /**
268
373
  * Run one pass now. Never rejects. Serialized: concurrent calls chain, so no two
269
374
  * passes overlap and each explicit call gets a pass that STARTS after the call. On a
@@ -272,8 +377,22 @@ export declare class StrandMembershipReconciler {
272
377
  * published — REPLACES the pending timer rather than running alongside it.
273
378
  */
274
379
  reconcile(): Promise<void>;
380
+ /** Chain `pass` after every pass already queued; the tail never rejects. */
381
+ private enqueue;
382
+ /**
383
+ * The re-arm itself, run at the head of the queued pass: `false` refuses the pass (a
384
+ * permanent stop), a running loop is left as it is, and a re-armable stop is reopened
385
+ * with the idle and unfinished counters, both escalation latches and the retry ladder
386
+ * reset — a fresh invitation is a fresh cycle.
387
+ */
388
+ private reopen;
275
389
  /** One serialized pass; contains every failure (contract: never rejects). */
276
390
  private doPass;
391
+ /**
392
+ * Steps 2 and 3 against a live database, with the outcome counted toward the probable
393
+ * blocked re-join report whether the attempt returned or threw.
394
+ */
395
+ private attempt;
277
396
  /**
278
397
  * Arm the next pass, now that this one has settled — so a slow pass never stacks ticks
279
398
  * behind it. Only a STARTED loop schedules: a caller driving {@link reconcile} by hand
@@ -296,28 +415,39 @@ export declare class StrandMembershipReconciler {
296
415
  */
297
416
  private ensureMembership;
298
417
  /**
299
- * The already-member arm: spend a still-staged invitation so nobody else can. Burn
300
- * failures (already spent, cancelled, expired — or a racing seal) are logged and
301
- * otherwise ignored, and the stage is cleared either way: with the member row
302
- * present the invitation has no further local use, and keeping a possibly-dead
303
- * credential staged would leave `getPendingMembershipInvite` lying. The one exception
304
- * is a busy refusal (the app had a transaction open): nothing was tried, so the
305
- * invitation stays staged and the next pass burns it.
418
+ * The already-member arm: spend a still-staged invitation so nobody else can. The stage
419
+ * is cleared once the burn lands or the invitation is known dead (already spent,
420
+ * cancelled, expired, or the strand sealed around a party that is still a member) — with
421
+ * the member row present a dead credential has no further local use, and keeping it
422
+ * staged would leave `getPendingMembershipInvite` lying. A refused burn keeps it staged:
423
+ * a busy refusal tried nothing, and a write the cohort refused is the removed-party shape
424
+ * ("Stops, and re-arming" in the module doc), where the local member row is stale and the
425
+ * credential is the one thing a later manager admission lets this loop settle.
306
426
  *
307
- * NOTE: accepted tradeoff — a burn that failed for a TRANSIENT reason is never
308
- * retried, so that bearer credential stays spendable until it expires. Weighed and
309
- * kept because it lands in the same state the strand already documents as normal
310
- * ("removal does not cancel an unspent invitation", docs/strands.md) and retrying
311
- * would mean keeping a dead-or-alive credential staged indefinitely. Revisit if
312
- * unspent invitations ever become a real admission risk — which is when
313
- * `feat-strand-member-allowlist-admission` lands.
427
+ * NOTE: this REPLACES an earlier accepted tradeoff that never retried a burn which failed
428
+ * for a transient reason and dropped the invitation instead. That decision was made when
429
+ * this arm could only ever see an invitation staged before the first join finished, so a
430
+ * dropped credential cost nothing but a spendable bearer token. {@link rearm} makes the
431
+ * arm reachable with a freshly issued invitation the app wants redeemed, and dropping
432
+ * that one on the first refused write is the silent failure this loop exists to avoid.
314
433
  */
315
434
  private burnLeftoverInvite;
435
+ /**
436
+ * Act on a `burnInvite` rejection as {@link classifyConsumeFailure} routes it. Unlike a
437
+ * redemption, a sealed strand is not terminal here — the member row is present, so this
438
+ * party is a member of the sealed strand and only its leftover credential is dead — and a
439
+ * half-committed burn (one collection, so a report of it means the commit's durability
440
+ * was in doubt) is retried: the next pass either lands it or fails on the row's primary
441
+ * key, which drops it as dead.
442
+ */
443
+ private handleBurnFailure;
316
444
  /**
317
445
  * Step 3 of the pass: write this machine's own `MemberPeer` binding (insert-if-absent)
318
- * and, on success, latch the done state and stop the loop. A missing transport peer id
319
- * (a quiesce racing the pass) defers to the next tick; a write failure — a busy refusal
320
- * included — is contained by the pass's outer catch and retried.
446
+ * and, once it is in place with nothing staged, latch the done state and stop the loop.
447
+ * A missing transport peer id (a quiesce racing the pass) defers to the next tick; a
448
+ * write failure — a busy refusal included — is contained by the pass's outer catch and
449
+ * retried. A binding written while an invitation is still staged (its burn keeps being
450
+ * refused) is not done: the loop stays live so a later pass can settle the credential.
321
451
  */
322
452
  private ensureBinding;
323
453
  /**
@@ -326,6 +456,26 @@ export declare class StrandMembershipReconciler {
326
456
  * invitation → drop; anything else → retry.
327
457
  */
328
458
  private handleConsumeFailure;
459
+ /**
460
+ * Count an attempted pass that left the invitation staged (whatever stopped it — a
461
+ * refused or busy write, or a read that threw on a cut-off machine); one that settled
462
+ * it, or finished, ends the streak. At {@link UNFINISHED_PASSES_BEFORE_ESCALATION}
463
+ * report the PROBABLE blocked re-join.
464
+ */
465
+ private noteAttemptOutcome;
466
+ /**
467
+ * Step 1's CONFIRMED trigger: self-revoked with an invitation staged. Reports (once per
468
+ * re-arm cycle) and answers `true` so the pass carries on — the redemption is attempted
469
+ * and refused like any other write, and a manager's re-admission lets a later pass finish.
470
+ * `false` (nothing staged) means step 1 stops the loop as before.
471
+ */
472
+ private stagedInviteBlockedBySelfRevocation;
473
+ /**
474
+ * ONE `console.warn` and one callback per re-arm cycle, whichever trigger fires first —
475
+ * see "Reporting a blocked re-join" in the module doc. `cause` is the trigger's own
476
+ * sentence; the remedy and the loop's posture are the same for both.
477
+ */
478
+ private reportRejoinBlocked;
329
479
  /**
330
480
  * A redemption only part of which was saved (see "Half-committed join" in the module doc):
331
481
  * ONE visible warning naming both halves, and the staged invitation dropped. The pass is not
@@ -356,6 +506,11 @@ export declare class StrandMembershipReconciler {
356
506
  * surface it) rather than shortening the interval.
357
507
  */
358
508
  private noteIdlePass;
359
- /** Stop with a reason — the terminal and done paths' shared exit. */
509
+ /**
510
+ * Stop with a reason — the terminal and done paths' shared exit. `permanent` says whether
511
+ * a fresh invitation may reopen the loop ("Stops, and re-arming" in the module doc).
512
+ */
360
513
  private finish;
514
+ /** Latch the stop and disarm the timer; a permanent latch sticks even on an already-stopped loop. */
515
+ private halt;
361
516
  }