@serfab/cadre-core 1.9.0 → 1.10.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 (87) hide show
  1. package/dist/cadre-node.d.ts +148 -17
  2. package/dist/cadre-node.js +361 -45
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-authorization.d.ts +2 -2
  5. package/dist/control-authorization.js +1 -0
  6. package/dist/control-authorization.js.map +1 -1
  7. package/dist/control-cohort.d.ts +1 -1
  8. package/dist/control-cohort.js +1 -1
  9. package/dist/control-database.d.ts +105 -14
  10. package/dist/control-database.js +242 -73
  11. package/dist/control-database.js.map +1 -1
  12. package/dist/control-formation-recorder.d.ts +6 -6
  13. package/dist/control-formation-recorder.js +7 -8
  14. package/dist/control-formation-recorder.js.map +1 -1
  15. package/dist/control-schema.d.ts +1 -1
  16. package/dist/control-schema.js +90 -3
  17. package/dist/control-schema.js.map +1 -1
  18. package/dist/control-write-retry.d.ts +21 -15
  19. package/dist/control-write-retry.js +21 -15
  20. package/dist/control-write-retry.js.map +1 -1
  21. package/dist/index.d.ts +5 -3
  22. package/dist/index.js +6 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/invitation-bootstrap.d.ts +32 -0
  25. package/dist/invitation-bootstrap.js +30 -0
  26. package/dist/invitation-bootstrap.js.map +1 -0
  27. package/dist/link-budget.d.ts +204 -119
  28. package/dist/link-budget.js +208 -120
  29. package/dist/link-budget.js.map +1 -1
  30. package/dist/membership-connection-gater.d.ts +3 -3
  31. package/dist/membership-connection-gater.js +3 -3
  32. package/dist/peer-dial.d.ts +18 -14
  33. package/dist/peer-dial.js +18 -14
  34. package/dist/peer-dial.js.map +1 -1
  35. package/dist/peer-join-backfill.d.ts +3 -3
  36. package/dist/pending-join-runner.d.ts +186 -0
  37. package/dist/pending-join-runner.js +649 -0
  38. package/dist/pending-join-runner.js.map +1 -0
  39. package/dist/push-fanout.d.ts +1 -1
  40. package/dist/push-fanout.js +1 -1
  41. package/dist/seed-bootstrap.d.ts +2 -2
  42. package/dist/seed-bootstrap.js +1 -1
  43. package/dist/strand-addr-protocol.js +2 -2
  44. package/dist/strand-formation-deadlines.d.ts +5 -5
  45. package/dist/strand-formation-deadlines.js +5 -5
  46. package/dist/strand-formation-manager.d.ts +18 -4
  47. package/dist/strand-formation-manager.js +48 -33
  48. package/dist/strand-formation-manager.js.map +1 -1
  49. package/dist/strand-formation-protocol.d.ts +71 -14
  50. package/dist/strand-formation-protocol.js +222 -47
  51. package/dist/strand-formation-protocol.js.map +1 -1
  52. package/dist/strand-formation-rejection.d.ts +94 -0
  53. package/dist/strand-formation-rejection.js +77 -0
  54. package/dist/strand-formation-rejection.js.map +1 -0
  55. package/dist/strand-instance-manager.js +4 -1
  56. package/dist/strand-instance-manager.js.map +1 -1
  57. package/dist/strand-solicitation.d.ts +11 -3
  58. package/dist/strand-solicitation.js +9 -0
  59. package/dist/strand-solicitation.js.map +1 -1
  60. package/dist/types.d.ts +103 -17
  61. package/dist/types.js +5 -9
  62. package/dist/types.js.map +1 -1
  63. package/package.json +22 -22
  64. package/src/cadre-node.ts +399 -46
  65. package/src/control-authorization.ts +117 -116
  66. package/src/control-cohort.ts +1 -1
  67. package/src/control-database.ts +303 -82
  68. package/src/control-formation-recorder.ts +8 -9
  69. package/src/control-schema.ts +90 -3
  70. package/src/control-write-retry.ts +21 -15
  71. package/src/index.ts +21 -1
  72. package/src/invitation-bootstrap.ts +40 -0
  73. package/src/link-budget.ts +221 -120
  74. package/src/membership-connection-gater.ts +3 -3
  75. package/src/peer-dial.ts +18 -14
  76. package/src/peer-join-backfill.ts +3 -3
  77. package/src/pending-join-runner.ts +735 -0
  78. package/src/push-fanout.ts +1 -1
  79. package/src/seed-bootstrap.ts +2 -2
  80. package/src/strand-addr-protocol.ts +2 -2
  81. package/src/strand-formation-deadlines.ts +5 -5
  82. package/src/strand-formation-manager.ts +62 -37
  83. package/src/strand-formation-protocol.ts +1036 -802
  84. package/src/strand-formation-rejection.ts +129 -0
  85. package/src/strand-instance-manager.ts +4 -1
  86. package/src/strand-solicitation.ts +17 -4
  87. package/src/types.ts +99 -17
@@ -0,0 +1,129 @@
1
+ /**
2
+ * How a formation dial fails, as types a joining device can branch on without reading the
3
+ * human-facing wording: a responder's refusal carries a fixed {@link FormationRejectionCode}
4
+ * on the wire and surfaces as a {@link FormationRejectedError}; a dial that never got an
5
+ * answer surfaces as a {@link FormationUnreachableError}.
6
+ */
7
+
8
+ /**
9
+ * Why a formation responder refused a join. Sent beside the free-text `reason` on every
10
+ * `approved: false` result frame (`FormationResultMessage.code`).
11
+ */
12
+ export type FormationRejectionCode =
13
+ /**
14
+ * No invitation row for this token on THIS responder. Retryable: a sibling machine of the
15
+ * inviter may not have received the row yet. Leaks nothing — only a holder of the token can
16
+ * ask, and an unknown token means the same as one never minted.
17
+ */
18
+ | 'token-unknown'
19
+ /**
20
+ * The invitation expired, or every allowed use is recorded. The loser of a redemption race
21
+ * and a latecomer both get this code, so neither can tell the two apart.
22
+ */
23
+ | 'token-spent'
24
+ /** The joiner's consent signature does not verify (or `partyId` does not embed `peerKey`). */
25
+ | 'consent-invalid'
26
+ /** The responder's disclosure validator refused the disclosure. */
27
+ | 'disclosure-invalid'
28
+ | 'disclosure-too-large'
29
+ /** The invitation's outside approval hook answered no. */
30
+ | 'approval-refused'
31
+ /** The invitation's outside approval hook could not be reached or did not answer in time. */
32
+ | 'approval-unavailable'
33
+ /** The inviter's approval setup is wrong: a malformed answer, an unenrolled key, or a misconfigured hook. */
34
+ | 'approval-invalid'
35
+ /**
36
+ * The host strand is not running (or not yet replicated) on this responder, or issuing the
37
+ * joiner's membership invitation into it failed.
38
+ */
39
+ | 'host-strand-unavailable'
40
+ /** The host strand predates the per-party identity split and can never admit a joiner. */
41
+ | 'host-strand-must-be-recreated'
42
+ /** The responder is at its cap on concurrent formation sessions. */
43
+ | 'busy'
44
+ /** Provisioning outran the responder's budget; the invitation is left unspent. */
45
+ | 'provisioning-timeout'
46
+ /** The consent write failed for a reason other than a spent invitation. */
47
+ | 'conflict'
48
+ /** An unexpected responder-side failure. */
49
+ | 'internal';
50
+
51
+ /**
52
+ * Which rejections are worth retrying — the single place that says so. A retry repeats the
53
+ * same redemption later (or against another of the inviter's machines); a final code means
54
+ * no retry of this invitation can succeed.
55
+ */
56
+ export const FORMATION_REJECTION_RETRYABLE: Readonly<Record<FormationRejectionCode, boolean>> = {
57
+ 'token-unknown': true,
58
+ 'token-spent': false,
59
+ 'consent-invalid': false,
60
+ 'disclosure-invalid': false,
61
+ 'disclosure-too-large': false,
62
+ 'approval-refused': false,
63
+ 'approval-unavailable': true,
64
+ 'approval-invalid': false,
65
+ 'host-strand-unavailable': true,
66
+ 'host-strand-must-be-recreated': false,
67
+ 'busy': true,
68
+ 'provisioning-timeout': true,
69
+ 'conflict': true,
70
+ 'internal': true
71
+ };
72
+
73
+ /** Is `value` a code this build knows? Own keys only, so a peer's `'constructor'` does not pass. */
74
+ export function isFormationRejectionCode(value: unknown): value is FormationRejectionCode {
75
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(FORMATION_REJECTION_RETRYABLE, value);
76
+ }
77
+
78
+ /**
79
+ * The responder answered and refused the join.
80
+ *
81
+ * `code` is `'unrecognized'` when the frame carried no code or one this build does not know
82
+ * — formation runs between two parties on different versions — and such a rejection counts
83
+ * as retryable: retrying an unknown answer until the invitation expires is safe, while giving
84
+ * up on one that would have succeeded is not.
85
+ */
86
+ export class FormationRejectedError extends Error {
87
+ readonly code: FormationRejectionCode | 'unrecognized';
88
+ readonly reason: string;
89
+ readonly retryable: boolean;
90
+
91
+ /** Both arguments come straight off the wire, so neither is trusted to have its declared type. */
92
+ constructor(code: unknown, reason: unknown) {
93
+ const text = typeof reason === 'string' ? reason : 'no reason provided';
94
+ super(`Formation rejected: ${text}`);
95
+ this.name = 'FormationRejectedError';
96
+ this.code = isFormationRejectionCode(code) ? code : 'unrecognized';
97
+ this.reason = text;
98
+ this.retryable = this.code === 'unrecognized' || FORMATION_REJECTION_RETRYABLE[this.code];
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The dial ended before a result frame was read: no responder address parsed, every address
104
+ * failed to dial, a deadline passed, or the stream closed early. Always retryable. Keeps the
105
+ * underlying failure's message, and the failure itself as `cause`.
106
+ */
107
+ export class FormationUnreachableError extends Error {
108
+ readonly retryable = true;
109
+
110
+ constructor(message: string, options?: { cause?: unknown }) {
111
+ super(message, options);
112
+ this.name = 'FormationUnreachableError';
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The responder approved the join, so the invitation's one-time token is spent, and then a
118
+ * step on the joining machine failed: persisting the party's membership identity, or
119
+ * remembering the join. Never retryable: the same invitation can only answer `token-spent`
120
+ * from now on, so recovery is fixing the local cause and redeeming a fresh invitation.
121
+ */
122
+ export class FormationPostApprovalError extends Error {
123
+ readonly retryable = false;
124
+
125
+ constructor(readonly strandId: string, message: string, options?: { cause?: unknown }) {
126
+ super(message, options);
127
+ this.name = 'FormationPostApprovalError';
128
+ }
129
+ }
@@ -43,7 +43,7 @@ import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPoli
43
43
  import { strandNodeAddrs } from './strand-network-config.js';
44
44
  import { resolveRelayServer } from './relay-server.js';
45
45
  import { superviseRelayReservation, type RelayReservationSupervisor } from './relay-reservation.js';
46
- import { declaredCohortReadDeadlineMs, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
46
+ import { declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
47
47
 
48
48
  const log = debug('sereus:cadre:strand-manager');
49
49
  const timing = debug('sereus:cadre:timing');
@@ -812,6 +812,9 @@ export class StrandInstanceManager {
812
812
  // node is also the listener for every other member's strand node, and Optimystic
813
813
  // derives that listener's limit from this (`link-budget.ts`).
814
814
  linkRoundTripMs: resolveLinkRoundTripMs(config.network?.linkRoundTripMs),
815
+ // The same dial limits as the control node: a strand node dials the same machines,
816
+ // through the same relays and gates.
817
+ ...optimysticDialLimits(config.network?.linkRoundTripMs),
815
818
  // Listen entries plus the WebSocket transport switch they imply — a strand node
816
819
  // announces nothing the operator configured (`strand-network-config.ts`), and
817
820
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -16,6 +16,7 @@ import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
16
16
  import { mintPlaceholderStrandId } from './strand-id.js';
17
17
  import {
18
18
  StrandFormationManager,
19
+ type CadrePeerAddrsSource,
19
20
  type MembershipInviteIssuer,
20
21
  type StrandFormationManagerConfig
21
22
  } from './strand-formation-manager.js';
@@ -110,9 +111,11 @@ export interface FormationUsageRecorder {
110
111
  isTokenUsed(token: string): Promise<boolean>;
111
112
 
112
113
  /**
113
- * Check if a token is valid and not expired
114
+ * Check if a token is valid and not expired. An invalid answer says why: `'unknown'` (no
115
+ * invitation row here — it may not have replicated yet) or `'expired'`. An answer without
116
+ * a reason is treated as `'unknown'`, the retryable one.
114
117
  */
115
- isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
118
+ isTokenValid(token: string): Promise<{ valid: boolean; reason?: 'unknown' | 'expired'; invitation?: OpenInvitation }>;
116
119
 
117
120
  /**
118
121
  * Resolve the host strand an invite binds to, classifying it as unbound / bound /
@@ -213,7 +216,7 @@ export interface StrandSolicitationServiceOptions {
213
216
  /** Party ID for this node (used in protocol messages) */
214
217
  partyId?: string;
215
218
  /** Cadre peer addresses for this node */
216
- cadrePeerAddrs?: string[];
219
+ cadrePeerAddrs?: CadrePeerAddrsSource;
217
220
  /**
218
221
  * This node's live STRAND-network multiaddrs for a strand it is running (responder
219
222
  * side), carried back to a validated joiner as its cross-party discovery seed. Wired
@@ -251,7 +254,7 @@ export class StrandSolicitationService {
251
254
  private readonly strandProvisioner?: StrandProvisioner;
252
255
  private readonly formationResponseValidator?: FormationResponseValidator;
253
256
  private readonly partyId: string;
254
- private readonly cadrePeerAddrs: string[];
257
+ private readonly cadrePeerAddrs: CadrePeerAddrsSource;
255
258
  private readonly resolveStrandAddrs?: (strandId: string) => string[];
256
259
  private readonly issueMembershipInvite?: MembershipInviteIssuer;
257
260
  private formationManager?: StrandFormationManager;
@@ -446,6 +449,16 @@ export class StrandSolicitationService {
446
449
  log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
447
450
  }
448
451
 
452
+ /**
453
+ * Take over every token `previous` minted or published, so a service that replaces it
454
+ * on a node keeps the connection gate open for invitations already handed out.
455
+ */
456
+ adoptMintedInvitations(previous: StrandSolicitationService): void {
457
+ for (const [token, expiresAtMs] of previous.mintedInvitations) {
458
+ this.mintedInvitations.set(token, expiresAtMs);
459
+ }
460
+ }
461
+
449
462
  /**
450
463
  * Does this node currently expect a stranger — i.e. is at least one open
451
464
  * invitation unexpired and not yet fully consumed? Sole input to the
package/src/types.ts CHANGED
@@ -17,6 +17,7 @@ import type { JoinedStrandStore } from './joined-strand-store.js';
17
17
  import type { PushNotifier } from './push-notifier.js';
18
18
  import type { RevocableTable } from './control-authorization.js';
19
19
  import type { ControlRetryAbandonment } from './control-retry.js';
20
+ import type { FormationRejectionCode } from './strand-formation-rejection.js';
20
21
 
21
22
  /**
22
23
  * Extended Libp2p node with the coordinatedRepo attached by db-p2p's
@@ -478,8 +479,9 @@ export interface NetworkConfig {
478
479
  * reservation drive, the control-cohort dial budgets, and the per-peer cohort read deadline
479
480
  * ({@link cohortQueryTimeoutMs}, unless set explicitly). It is also handed to Optimystic
480
481
  * (`NodeOptions.linkRoundTripMs`), default included, which derives its own deadlines from it:
481
- * its request dials and responses, its block pushes, and libp2p's `dialTimeout` and
482
- * `inboundUpgradeTimeout` on every node. Reaching another machine through a
482
+ * its request dials and responses, its block pushes, and libp2p's `addressDialTimeout`,
483
+ * `dialTimeout` and `inboundUpgradeTimeout` on every node, cadre adding its admission decisions
484
+ * to the three that open a connection. Reaching another machine through a
483
485
  * relay costs a fixed number of exchanges, so a deadline written as milliseconds has a link
484
486
  * speed above which it can never open a connection — which is the defect this declaration
485
487
  * exists to make impossible to reintroduce one budget at a time. The counts, the measurement behind them,
@@ -496,8 +498,12 @@ export interface NetworkConfig {
496
498
  * Refused where the libp2p node is built — inside `CadreNode.start()` for the control
497
499
  * network, inside `CadreNode.addStrand` for a strand — if it is not a finite number above
498
500
  * zero, because every consumer multiplies it into a deadline where a zero means "give up at
499
- * once" and a `NaN` means "never". Optimystic refuses, in the same place, a value above its
500
- * `MAX_LINK_ROUND_TRIP_MS` (about 1.66 days).
501
+ * once" and a `NaN` means "never". A value above about 10.8 hours is refused in the same place,
502
+ * by Optimystic: the request dial cadre states from it (eleven round trips plus two admission
503
+ * decisions, `optimysticDialLimits` in `link-budget.ts`) then exceeds its
504
+ * `MAX_RPC_DIAL_TIMEOUT_MS` (about 4.97 days), and its error names `rpcDeadlines.dialTimeoutMs`.
505
+ * That is below Optimystic's own `MAX_LINK_ROUND_TRIP_MS` (about 13.3 hours) and far above any
506
+ * real link, so cadre adds no check of its own.
501
507
  */
502
508
  linkRoundTripMs?: number;
503
509
  /**
@@ -569,8 +575,8 @@ export interface NetworkConfig {
569
575
  * so an address that never answers cannot use up the time the peer's other
570
576
  * addresses needed. Defaults to
571
577
  * {@link DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS}. A value given
572
- * here replaces that whole derived budget, including its allowance for the
573
- * called machine's admission decision (`relayedDialBudgetMs`).
578
+ * here replaces that whole derived budget, including its allowances for the
579
+ * relay's and the called machine's admission decisions (`relayedDialBudgetMs`).
574
580
  */
575
581
  perAddressDialTimeoutMs?: number;
576
582
  /**
@@ -631,15 +637,11 @@ export interface NetworkConfig {
631
637
  *
632
638
  * WHY THE DEADLINE IS PINNED rather than given room to adapt. `pingTimeout` is an
633
639
  * adaptive-timeout init, and equal `minTimeout`/`maxTimeout` clamp it to one value on
634
- * every libp2p version. Under libp2p 3.1.3, which sereus resolves today, it is already
635
- * flat at `minTimeout`: `ConnectionMonitor` asks its `AdaptiveTimeout` for a deadline but
636
- * never calls `cleanUp` to report how long the ping took, so the moving average the
637
- * deadline derives from stays at zero. libp2p 3.3 does report ping durations back, and a
638
- * ceiling above `pingInterval` would then let the deadline grow past the interval and put
639
- * the overlapping-ping abort above straight back. Pinning both ends keeps the interval's
640
- * margin true on the version bump instead of making it something to remember. It also
641
- * sidesteps 3.3's other surprise: the monitor keeps one `AdaptiveTimeout` for all of a
642
- * node's connections, so one slow peer would otherwise lengthen the deadline for every
640
+ * every libp2p version. libp2p 3.3 reports each ping's duration back to the monitor's
641
+ * `AdaptiveTimeout`, so a ceiling above `pingInterval` would let the deadline grow past the
642
+ * interval and put the overlapping-ping abort above straight back. Pinning both ends keeps
643
+ * the interval's margin true. It also sidesteps the monitor keeping one `AdaptiveTimeout`
644
+ * for all of a node's connections, so one slow peer cannot lengthen the deadline for every
643
645
  * connection on that node.
644
646
  */
645
647
  export const DEFAULT_CONNECTION_MONITOR = Object.freeze({
@@ -1198,6 +1200,69 @@ export interface StrandRow {
1198
1200
  FounderOwnerKey: string | null;
1199
1201
  }
1200
1202
 
1203
+ /**
1204
+ * A `CadreControl.PendingJoin` row: a join this party asked for through another party's
1205
+ * invitation, readable by every machine of the party. Each outcome carries only its own
1206
+ * columns (the schema's `OutcomeShape` check): `'joined'` sets `StrandId` (and, for a
1207
+ * closed strand, `MembershipInvite`), `'failed'` sets `FailureCode`, and a pending row
1208
+ * (`Outcome: null`) sets neither.
1209
+ */
1210
+ export interface PendingJoinRow {
1211
+ /** base64url sha256 of the invitation token (`pendingJoinId`). */
1212
+ Id: string;
1213
+ /** `CadreNode.encodeInvitation` of the invitation. A bearer credential. */
1214
+ Invitation: string;
1215
+ /** `canonicalJson` of the disclosure the requester gave. */
1216
+ Disclosure: string;
1217
+ /** Epoch ms. */
1218
+ RequestedAt: number;
1219
+ /** Epoch ms; no attempt starts at or after it. */
1220
+ ExpiresAt: number;
1221
+ Outcome: null | 'joined' | 'failed';
1222
+ /** Epoch ms the outcome was recorded; null while pending. */
1223
+ OutcomeAt: number | null;
1224
+ /** `'joined'`: the strand the formation returned. */
1225
+ StrandId: string | null;
1226
+ /** `'joined'`, closed strand: JSON of the {@link StrandMembershipInvite} the formation delivered. */
1227
+ MembershipInvite: string | null;
1228
+ /** `'failed'`: a `FormationRejectionCode`, `'expired'` or `'local'`. Not constrained by the schema. */
1229
+ FailureCode: string | null;
1230
+ /** `'failed'`: human-readable text. */
1231
+ FailureReason: string | null;
1232
+ /** Single-use nonce of this row incarnation; `ControlDatabase.replacePendingJoin` names it as the row it replaces. */
1233
+ StampId: string;
1234
+ }
1235
+
1236
+ /**
1237
+ * A join this party asked for through `CadreNode.requestJoin`, as one machine sees it.
1238
+ *
1239
+ * `pending`, `joined` and `failed` come from the party-wide `PendingJoin` row and read the same
1240
+ * on every machine. `trying` (an attempt is running on this machine) and `waiting` (this
1241
+ * machine's last attempt failed in a way worth retrying) are this machine's own view of a
1242
+ * pending row, so two machines can report different states for it.
1243
+ */
1244
+ export interface PendingJoinStatus {
1245
+ /** `PendingJoin.Id`: the sha256 of the invitation token. */
1246
+ id: string;
1247
+ sAppId: string;
1248
+ /** Epoch ms. */
1249
+ requestedAt: number;
1250
+ /** Epoch ms; no attempt starts at or after it. */
1251
+ expiresAt: number;
1252
+ state: 'pending' | 'trying' | 'waiting' | 'joined' | 'failed';
1253
+ /** `'waiting'`: when this machine tries again, epoch ms. */
1254
+ nextAttemptAt?: number;
1255
+ /**
1256
+ * `'waiting'`: why this machine's last attempt failed. `'unreachable'` means no answer came
1257
+ * back; `'local'` means the attempt failed on this machine before an answer.
1258
+ */
1259
+ lastError?: { code: FormationRejectionCode | 'unrecognized' | 'unreachable' | 'local'; reason: string };
1260
+ /** `'joined'`: the strand the join produced. */
1261
+ strandId?: string;
1262
+ /** `'failed'`: a `FormationRejectionCode`, `'expired'`, or `'local'` (approved, then a step on the joining machine failed). */
1263
+ failure?: { code: string; reason: string };
1264
+ }
1265
+
1201
1266
  /**
1202
1267
  * sApp configuration provided by the hosting application when creating a strand.
1203
1268
  * This is what the app developer provides - NOT loaded from the network.
@@ -1374,7 +1439,13 @@ export interface OpenInvitation {
1374
1439
  sAppId: string;
1375
1440
  /** When this invitation expires */
1376
1441
  expiration: Date;
1377
- /** Bootstrap addresses to contact the inviter's cadre */
1442
+ /**
1443
+ * Addresses of the inviting party's machines, each ending in `/p2p/<peerId>`: the minting
1444
+ * machine's own first, then up to three of its siblings with up to four addresses each
1445
+ * (`CadreNode.createOpenInvitation`). A joiner tries the machines in order, one formation
1446
+ * session each, so an entry may be dead by the time it is used; one that names no machine
1447
+ * is ignored.
1448
+ */
1378
1449
  bootstrap: string[];
1379
1450
  }
1380
1451
 
@@ -1394,7 +1465,11 @@ export interface StrandMembershipInvite {
1394
1465
  * The invite ed25519 PRIVATE seed (base64url). A single-use bearer credential:
1395
1466
  * whoever holds it can `consumeInvite` exactly once. Same sensitivity class and
1396
1467
  * handling as `memberPrivateKey` — delivered only inside the validated,
1397
- * post-approval formation result, never written to either side's control DB.
1468
+ * post-approval formation result. The inviting side never writes it to its control
1469
+ * DB. The joining side may copy it into its own `PendingJoin` row
1470
+ * (`MembershipInvite`), owner-signed and party-private like
1471
+ * `JoinedStrand.MemberPrivateKey`, so a join finished on one machine of the party
1472
+ * can be seated by whichever machine launches the strand first.
1398
1473
  */
1399
1474
  invitePrivateKey: string;
1400
1475
  }
@@ -1601,6 +1676,13 @@ export interface CadreNodeEvents {
1601
1676
  * or connection state change.
1602
1677
  */
1603
1678
  'control:write-abandoned': ControlRetryAbandonment;
1679
+ /**
1680
+ * Emitted when this machine's view of a join asked for with `CadreNode.requestJoin` changes:
1681
+ * its own attempt started or ended, or a pass read an outcome another owner machine wrote.
1682
+ * Owner machines only, since only they run the retry loop. A dismissed join emits nothing
1683
+ * further; `listPendingJoins` no longer names it.
1684
+ */
1685
+ 'pendingJoin:changed': PendingJoinStatus;
1604
1686
  /** Emitted when a seed is received via the seed protocol */
1605
1687
  'seed:received': { partyId: string; peerId: string };
1606
1688
  /** Emitted when a seed is successfully applied */