@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
@@ -308,8 +308,10 @@ export declare class CadreNode implements SAppIdLookup {
308
308
  * (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
309
309
  * in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
310
310
  * against an already-seated member, or found dead (expired / cancelled / consumed
311
- * elsewhere / the strand sealed). Until the strand is actually launched here the entry
312
- * just waits.
311
+ * elsewhere / the strand sealed) — naming the invitation it settled, so a re-formation
312
+ * that replaced the entry meanwhile keeps its fresh one
313
+ * ({@link unstageMembershipInvite}). Until the strand is actually launched here the entry
314
+ * just waits; once launched, staging notifies the manager, which re-arms a finished loop.
313
315
  *
314
316
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
315
317
  * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
@@ -1357,9 +1359,14 @@ export declare class CadreNode implements SAppIdLookup {
1357
1359
  * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
1358
1360
  * `dialInvite`.
1359
1361
  *
1360
- * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why a peer's dial is
1361
- * bounded as a whole, and {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}
1362
- * for why each address is bounded as well.
1362
+ * See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
1363
+ * bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
1364
+ * address is bounded as well.
1365
+ *
1366
+ * Both are DERIVED from `network.linkRoundTripMs` rather than fixed, because the slowest
1367
+ * address either has to cover is a relayed dial and that costs a fixed number of exchanges —
1368
+ * `link-budget.ts` has the counts. A host's explicit `controlCohort` values still win, so a
1369
+ * test that drives dead addresses on purpose keeps the duration it chose.
1363
1370
  */
1364
1371
  private controlDialBudget;
1365
1372
  /**
@@ -2381,8 +2388,10 @@ export declare class CadreNode implements SAppIdLookup {
2381
2388
  * caller runs the drive regardless).
2382
2389
  *
2383
2390
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
2384
- * timeouts (10 s each: dial by peer id, then by addr) before the 10 s drive even
2385
- * starts, so one failed re-drive can hold the supervisor `driving` for ~30 s.
2391
+ * timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
2392
+ * starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
2393
+ * drive's own deadline — 34 s at the default declared link round trip, and longer on a host
2394
+ * that declared a slower one (`link-budget.ts`).
2386
2395
  * Bounded and harmless while the relay is unreachable anyway; if recovery
2387
2396
  * latency after a relay comes back ever matters, skip the announce when the
2388
2397
  * control node holds no connection to the relay (the drive's own dial fails
@@ -2602,13 +2611,15 @@ export declare class CadreNode implements SAppIdLookup {
2602
2611
  * local), and only then does it reach out. Everything above in {@link start}
2603
2612
  * has completed by the time this runs.
2604
2613
  *
2605
- * BUDGET: the supervisor's first attempt — `relay-reservation.ts`'s
2606
- * `DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s — is what `start()` waits on — deliberately the module default rather
2607
- * than a boot-specific one. A healthy dial-plus-reserve is sub-second even over
2608
- * a WAN, so 10 s is slack for a slow link; going much longer would make a dead
2609
- * relay indistinguishable from a hung start, and much shorter would fail nodes
2610
- * on links that were merely slow. The retries carry on in the background after
2611
- * this resolves, exactly as they do for a {@link reserveRelays} caller.
2614
+ * BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
2615
+ * ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
2616
+ * round trips at the declared `network.linkRoundTripMs` (14 s at its default, where it was a
2617
+ * fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
2618
+ * a slow link, while going much longer would make a dead relay indistinguishable from a hung
2619
+ * start and much shorter would fail nodes on links that were merely slow. A host that declares
2620
+ * a slower link lengthens it without touching this path — `link-budget.ts`. The retries carry
2621
+ * on in the background after this resolves, exactly as they do for a {@link reserveRelays}
2622
+ * caller.
2612
2623
  *
2613
2624
  * `network.requireRelay === false` softens only the outcome below: a first
2614
2625
  * attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
@@ -3057,7 +3068,11 @@ export declare class CadreNode implements SAppIdLookup {
3057
3068
  * an intact control DB) and mints + persists a fresh one otherwise. The invitation
3058
3069
  * will admit THIS key's public half as the `Strand.Member`.
3059
3070
  * 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
3060
- * bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`.
3071
+ * bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`,
3072
+ * and tell the instance manager, which re-arms an already-finished membership
3073
+ * reconciler so a RE-formation against a launched strand is attempted at once — the
3074
+ * removed-party case, where the loop finished long before the removal. A strand not
3075
+ * launched here yet has no loop to re-arm, and its bring-up finds the entry.
3061
3076
  *
3062
3077
  * Throws — failing the whole {@link formStrand} — when the identity cannot be
3063
3078
  * persisted: a joiner "joined" without a persistable identity could never become a
@@ -3067,6 +3082,14 @@ export declare class CadreNode implements SAppIdLookup {
3067
3082
  * rejected) and redeeming a FRESH invitation.
3068
3083
  */
3069
3084
  private adoptFormationMembershipInvite;
3085
+ /**
3086
+ * The reconciler's half of the {@link pendingMembershipInvites} seam: drop `settled`
3087
+ * (spent, burned, or dead) only while it is still the staged entry. A re-formation
3088
+ * replaces the entry between a pass's read and its settle, and the fresh invitation it
3089
+ * staged is the one the re-armed loop is about to redeem — deleting it here would lose
3090
+ * it silently, the very outcome the re-arm exists to prevent.
3091
+ */
3092
+ private unstageMembershipInvite;
3070
3093
  /**
3071
3094
  * The pending single-use membership invitation a closed-strand formation carried back
3072
3095
  * for `strandId`, or `undefined` when none is staged — the seam the strand bring-up
@@ -27,7 +27,8 @@ import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manag
27
27
  import { PeerJoinBackfill } from './peer-join-backfill.js';
28
28
  import { deriveCohortMembers } from './strand-cohort.js';
29
29
  import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
30
- import { dialPeerAddrs, DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS, DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS } from './peer-dial.js';
30
+ import { dialPeerAddrs, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
31
+ import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
31
32
  import { EnrollmentService } from './enrollment.js';
32
33
  import { HibernationManager } from './hibernation-manager.js';
33
34
  import { ControlDatabase, isStrandIdConflict } from './control-database.js';
@@ -423,8 +424,10 @@ export class CadreNode {
423
424
  * (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
424
425
  * in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
425
426
  * against an already-seated member, or found dead (expired / cancelled / consumed
426
- * elsewhere / the strand sealed). Until the strand is actually launched here the entry
427
- * just waits.
427
+ * elsewhere / the strand sealed) — naming the invitation it settled, so a re-formation
428
+ * that replaced the entry meanwhile keeps its fresh one
429
+ * ({@link unstageMembershipInvite}). Until the strand is actually launched here the entry
430
+ * just waits; once launched, staging notifies the manager, which re-arms a finished loop.
428
431
  *
429
432
  * NOTE: entries live for the node's lifetime (one small pair per formed closed
430
433
  * strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
@@ -762,6 +765,11 @@ export class CadreNode {
762
765
  // throws on a malformed `relayAddrs` entry, and that failure belongs on the same
763
766
  // logged-and-cleaned-up path as every other one in start().
764
767
  this.warnIfAnnounceAddrsDiscardRelay();
768
+ // Refuse a bad `linkRoundTripMs` HERE rather than wherever a budget is first derived
769
+ // from it: every consumer of it is conditional (no control storage, no relays, no dial
770
+ // yet), so a node with a zero or NaN declaration would otherwise boot and fail later
771
+ // inside a best-effort path that logs and carries on. `link-budget.ts`.
772
+ resolveLinkRoundTripMs(this.config.network?.linkRoundTripMs);
765
773
  // Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
766
774
  // wraps globalThis.RTCPeerConnection before the control node can create one.
767
775
  // Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
@@ -1267,6 +1275,11 @@ export class CadreNode {
1267
1275
  // before looking anywhere else; if catch-up pushes start showing up as
1268
1276
  // connection-churn noise, raise it.
1269
1277
  debounceMs: 250,
1278
+ // Dial and response deadlines counted in link round trips, not fixed milliseconds — a
1279
+ // relayed dial that cannot finish inside the budget fails identically on every retry.
1280
+ // See `link-budget.ts`. Spread BEFORE the host's own config so an explicit
1281
+ // `controlBackfill.dialTimeoutMs` still wins.
1282
+ ...peerJoinPushBudget(this.config.network?.linkRoundTripMs),
1270
1283
  ...this.config.controlBackfill
1271
1284
  });
1272
1285
  this.controlBackfill.start();
@@ -1396,7 +1409,13 @@ export class CadreNode {
1396
1409
  // itself, so the unknown case is provably byte-for-byte the old behaviour.
1397
1410
  // The yardstick moves ALONE: `assumedClusterSize` stays pinned at 2, because a
1398
1411
  // party of phones cannot promise three quarters of its machines are awake.
1399
- clusterPolicy: controlClusterPolicy(this.declaredEnrolledMachines),
1412
+ // With neither declared this is still the frozen constant by identity, which is the
1413
+ // production path. A degenerate deadline is Optimystic's to refuse, in
1414
+ // createControlNode below.
1415
+ clusterPolicy: controlClusterPolicy({
1416
+ enrolledMachines: this.declaredEnrolledMachines,
1417
+ cohortQueryTimeoutMs: network?.cohortQueryTimeoutMs
1418
+ }),
1400
1419
  arachnode: { enableRingZulu: profile === 'storage' },
1401
1420
  ...(identityKey && { privateKey: identityKey }),
1402
1421
  ...(network?.transports && { transports: network.transports }),
@@ -1406,6 +1425,10 @@ export class CadreNode {
1406
1425
  // whose event loop is saturated by pure-JS Noise crypto. A configured value
1407
1426
  // replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
1408
1427
  connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
1428
+ // libp2p's own dial and inbound-upgrade limits, from the same declared link as every
1429
+ // cadre dial budget. Left to db-p2p, both stay at 10 s, which cannot open a relayed
1430
+ // connection above a 2.5 s round trip — and the listener's side of that fails silently.
1431
+ connectionManager: connectionManagerTimeouts(network?.linkRoundTripMs),
1409
1432
  // `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
1410
1433
  // `{}` when `network.transports` is set, since the embedder owns transport policy
1411
1434
  // then. Spread NEXT to `transports` because the two answer the same question.
@@ -2753,15 +2776,22 @@ export class CadreNode {
2753
2776
  * {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
2754
2777
  * `dialInvite`.
2755
2778
  *
2756
- * See {@link DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS} for why a peer's dial is
2757
- * bounded as a whole, and {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}
2758
- * for why each address is bounded as well.
2779
+ * See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
2780
+ * bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
2781
+ * address is bounded as well.
2782
+ *
2783
+ * Both are DERIVED from `network.linkRoundTripMs` rather than fixed, because the slowest
2784
+ * address either has to cover is a relayed dial and that costs a fixed number of exchanges —
2785
+ * `link-budget.ts` has the counts. A host's explicit `controlCohort` values still win, so a
2786
+ * test that drives dead addresses on purpose keeps the duration it chose.
2759
2787
  */
2760
2788
  controlDialBudget() {
2761
2789
  const cohort = this.config.network?.controlCohort;
2790
+ const perAddressMs = cohort?.perAddressDialTimeoutMs
2791
+ ?? relayedDialBudgetMs(this.config.network?.linkRoundTripMs);
2762
2792
  return {
2763
- perAddressMs: cohort?.perAddressDialTimeoutMs ?? DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS,
2764
- totalMs: cohort?.dialTimeoutMs ?? DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS,
2793
+ perAddressMs,
2794
+ totalMs: cohort?.dialTimeoutMs ?? CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS * perAddressMs,
2765
2795
  };
2766
2796
  }
2767
2797
  /**
@@ -4906,6 +4936,7 @@ export class CadreNode {
4906
4936
  revocationEnforcement: this.config.strandRevocationEnforcement,
4907
4937
  membershipReconciliation: this.config.strandMembershipReconciliation,
4908
4938
  onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
4939
+ onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
4909
4940
  // The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
4910
4941
  // comes up `'syncing'` announces the moment its database is published.
4911
4942
  firstSync: this.config.strandFirstSync,
@@ -4919,7 +4950,7 @@ export class CadreNode {
4919
4950
  // cleared once spent, burned, or dead — see pendingMembershipInvites.
4920
4951
  pendingMembershipInvite: {
4921
4952
  get: () => this.pendingMembershipInvites.get(strand.Id),
4922
- clear: () => { this.pendingMembershipInvites.delete(strand.Id); }
4953
+ clear: (settled) => this.unstageMembershipInvite(strand.Id, settled)
4923
4954
  },
4924
4955
  // The RESOLVED flag, never the raw argument — see the doc comment above.
4925
4956
  founder: resolvedFounder,
@@ -5147,8 +5178,10 @@ export class CadreNode {
5147
5178
  * caller runs the drive regardless).
5148
5179
  *
5149
5180
  * NOTE: against a relay that is DOWN this hook costs up to two strand-addr
5150
- * timeouts (10 s each: dial by peer id, then by addr) before the 10 s drive even
5151
- * starts, so one failed re-drive can hold the supervisor `driving` for ~30 s.
5181
+ * timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
5182
+ * starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
5183
+ * drive's own deadline — 34 s at the default declared link round trip, and longer on a host
5184
+ * that declared a slower one (`link-budget.ts`).
5152
5185
  * Bounded and harmless while the relay is unreachable anyway; if recovery
5153
5186
  * latency after a relay comes back ever matters, skip the announce when the
5154
5187
  * control node holds no connection to the relay (the drive's own dial fails
@@ -5646,13 +5679,15 @@ export class CadreNode {
5646
5679
  * local), and only then does it reach out. Everything above in {@link start}
5647
5680
  * has completed by the time this runs.
5648
5681
  *
5649
- * BUDGET: the supervisor's first attempt — `relay-reservation.ts`'s
5650
- * `DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s — is what `start()` waits on — deliberately the module default rather
5651
- * than a boot-specific one. A healthy dial-plus-reserve is sub-second even over
5652
- * a WAN, so 10 s is slack for a slow link; going much longer would make a dead
5653
- * relay indistinguishable from a hung start, and much shorter would fail nodes
5654
- * on links that were merely slow. The retries carry on in the background after
5655
- * this resolves, exactly as they do for a {@link reserveRelays} caller.
5682
+ * BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
5683
+ * ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
5684
+ * round trips at the declared `network.linkRoundTripMs` (14 s at its default, where it was a
5685
+ * fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
5686
+ * a slow link, while going much longer would make a dead relay indistinguishable from a hung
5687
+ * start and much shorter would fail nodes on links that were merely slow. A host that declares
5688
+ * a slower link lengthens it without touching this path — `link-budget.ts`. The retries carry
5689
+ * on in the background after this resolves, exactly as they do for a {@link reserveRelays}
5690
+ * caller.
5656
5691
  *
5657
5692
  * `network.requireRelay === false` softens only the outcome below: a first
5658
5693
  * attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
@@ -5714,7 +5749,13 @@ export class CadreNode {
5714
5749
  this.relayReserveError = 'control node unavailable';
5715
5750
  return this.getRelayReservationState();
5716
5751
  }
5717
- const supervisor = superviseRelayReservation(this.controlNode, addrs, opts);
5752
+ // The drive's deadline is counted in link round trips (`link-budget.ts`), so a host that
5753
+ // declared a slower link gets a longer drive without naming a number here. A caller's own
5754
+ // `timeoutMs` still wins.
5755
+ const supervisor = superviseRelayReservation(this.controlNode, addrs, {
5756
+ timeoutMs: relayReservationBudgetMs(this.config.network?.linkRoundTripMs),
5757
+ ...opts
5758
+ });
5718
5759
  this.relayReserveSupervisor = supervisor;
5719
5760
  await supervisor.firstAttempt;
5720
5761
  // Read through `this`, not `supervisor`: an overlapping call may have replaced
@@ -6558,7 +6599,11 @@ export class CadreNode {
6558
6599
  * an intact control DB) and mints + persists a fresh one otherwise. The invitation
6559
6600
  * will admit THIS key's public half as the `Strand.Member`.
6560
6601
  * 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
6561
- * bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`.
6602
+ * bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`,
6603
+ * and tell the instance manager, which re-arms an already-finished membership
6604
+ * reconciler so a RE-formation against a launched strand is attempted at once — the
6605
+ * removed-party case, where the loop finished long before the removal. A strand not
6606
+ * launched here yet has no loop to re-arm, and its bring-up finds the entry.
6562
6607
  *
6563
6608
  * Throws — failing the whole {@link formStrand} — when the identity cannot be
6564
6609
  * persisted: a joiner "joined" without a persistable identity could never become a
@@ -6577,16 +6622,23 @@ export class CadreNode {
6577
6622
  'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
6578
6623
  'invitation (the delivered one dies with this error).', { cause: error });
6579
6624
  }
6580
- // NOTE: staging alone is not enough on a node whose reconciler for this strand has
6581
- // already finished. `StrandMembershipReconciler` latches a terminal stopped state on
6582
- // its `done` path and is rebuilt only by a strand relaunch, so a RE-formation against
6583
- // an already-launched strand stages an invitation no loop will ever look at. Harmless
6584
- // for a party that is still a member (the invitation would only be burned), and the
6585
- // live defect for one that has been REMOVED — which is exactly when an app re-forms.
6586
- // Tracked as `backlog/bug-removed-party-cannot-redeem-its-way-back`; pinned by
6587
- // `strand-party-removal-via-formation-e2e.integration.ts` (test 2).
6588
6625
  this.pendingMembershipInvites.set(strandId, invite);
6589
6626
  log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
6627
+ this.strandManager.notifyMembershipInviteStaged(strandId);
6628
+ }
6629
+ /**
6630
+ * The reconciler's half of the {@link pendingMembershipInvites} seam: drop `settled`
6631
+ * (spent, burned, or dead) only while it is still the staged entry. A re-formation
6632
+ * replaces the entry between a pass's read and its settle, and the fresh invitation it
6633
+ * staged is the one the re-armed loop is about to redeem — deleting it here would lose
6634
+ * it silently, the very outcome the re-arm exists to prevent.
6635
+ */
6636
+ unstageMembershipInvite(strandId, settled) {
6637
+ if (this.pendingMembershipInvites.get(strandId)?.inviteKey !== settled.inviteKey) {
6638
+ log('strand %s: a fresh membership invitation replaced the one just settled — keeping it staged', strandId);
6639
+ return;
6640
+ }
6641
+ this.pendingMembershipInvites.delete(strandId);
6590
6642
  }
6591
6643
  /**
6592
6644
  * The pending single-use membership invitation a closed-strand formation carried back