@serfab/cadre-core 1.8.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 (126) hide show
  1. package/dist/cadre-node.d.ts +301 -121
  2. package/dist/cadre-node.js +588 -244
  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 +7 -7
  22. package/dist/index.js +16 -13
  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/node-local-snapshot.d.ts +6 -4
  33. package/dist/node-local-snapshot.js +7 -3
  34. package/dist/node-local-snapshot.js.map +1 -1
  35. package/dist/peer-dial.d.ts +18 -14
  36. package/dist/peer-dial.js +18 -14
  37. package/dist/peer-dial.js.map +1 -1
  38. package/dist/peer-join-backfill.d.ts +3 -12
  39. package/dist/peer-join-backfill.js +2 -3
  40. package/dist/peer-join-backfill.js.map +1 -1
  41. package/dist/pending-join-runner.d.ts +186 -0
  42. package/dist/pending-join-runner.js +649 -0
  43. package/dist/pending-join-runner.js.map +1 -0
  44. package/dist/push-fanout.d.ts +1 -1
  45. package/dist/push-fanout.js +1 -1
  46. package/dist/seed-bootstrap.d.ts +2 -2
  47. package/dist/seed-bootstrap.js +1 -1
  48. package/dist/strand-addr-protocol.js +2 -2
  49. package/dist/strand-formation-deadlines.d.ts +5 -5
  50. package/dist/strand-formation-deadlines.js +5 -5
  51. package/dist/strand-formation-manager.d.ts +18 -4
  52. package/dist/strand-formation-manager.js +48 -33
  53. package/dist/strand-formation-manager.js.map +1 -1
  54. package/dist/strand-formation-protocol.d.ts +71 -25
  55. package/dist/strand-formation-protocol.js +225 -51
  56. package/dist/strand-formation-protocol.js.map +1 -1
  57. package/dist/strand-formation-rejection.d.ts +94 -0
  58. package/dist/strand-formation-rejection.js +77 -0
  59. package/dist/strand-formation-rejection.js.map +1 -0
  60. package/dist/strand-fret-addrs.d.ts +54 -0
  61. package/dist/strand-fret-addrs.js +108 -0
  62. package/dist/strand-fret-addrs.js.map +1 -0
  63. package/dist/strand-instance-manager.d.ts +9 -38
  64. package/dist/strand-instance-manager.js +17 -74
  65. package/dist/strand-instance-manager.js.map +1 -1
  66. package/dist/strand-network-state-file.d.ts +12 -0
  67. package/dist/strand-network-state-file.js +38 -0
  68. package/dist/strand-network-state-file.js.map +1 -0
  69. package/dist/strand-network-state.d.ts +111 -0
  70. package/dist/strand-network-state.js +191 -0
  71. package/dist/strand-network-state.js.map +1 -0
  72. package/dist/strand-solicitation.d.ts +11 -3
  73. package/dist/strand-solicitation.js +9 -0
  74. package/dist/strand-solicitation.js.map +1 -1
  75. package/dist/types.d.ts +123 -40
  76. package/dist/types.js +5 -9
  77. package/dist/types.js.map +1 -1
  78. package/package.json +25 -24
  79. package/src/cadre-node.ts +637 -251
  80. package/src/control-authorization.ts +117 -116
  81. package/src/control-cohort.ts +1 -1
  82. package/src/control-database.ts +303 -82
  83. package/src/control-formation-recorder.ts +8 -9
  84. package/src/control-schema.ts +90 -3
  85. package/src/control-write-retry.ts +21 -15
  86. package/src/index.ts +36 -56
  87. package/src/invitation-bootstrap.ts +40 -0
  88. package/src/link-budget.ts +221 -120
  89. package/src/membership-connection-gater.ts +3 -3
  90. package/src/node-local-snapshot.ts +12 -7
  91. package/src/peer-dial.ts +18 -14
  92. package/src/peer-join-backfill.ts +5 -6
  93. package/src/pending-join-runner.ts +735 -0
  94. package/src/push-fanout.ts +1 -1
  95. package/src/seed-bootstrap.ts +2 -2
  96. package/src/strand-addr-protocol.ts +2 -2
  97. package/src/strand-formation-deadlines.ts +5 -5
  98. package/src/strand-formation-manager.ts +62 -37
  99. package/src/strand-formation-protocol.ts +1036 -803
  100. package/src/strand-formation-rejection.ts +129 -0
  101. package/src/strand-fret-addrs.ts +145 -0
  102. package/src/strand-instance-manager.ts +24 -97
  103. package/src/strand-network-state-file.ts +40 -0
  104. package/src/strand-network-state.ts +243 -0
  105. package/src/strand-solicitation.ts +17 -4
  106. package/src/types.ts +119 -40
  107. package/dist/strand-peer-book-file.d.ts +0 -12
  108. package/dist/strand-peer-book-file.js +0 -37
  109. package/dist/strand-peer-book-file.js.map +0 -1
  110. package/dist/strand-peer-book-protocol.d.ts +0 -168
  111. package/dist/strand-peer-book-protocol.js +0 -356
  112. package/dist/strand-peer-book-protocol.js.map +0 -1
  113. package/dist/strand-peer-book-swap.d.ts +0 -104
  114. package/dist/strand-peer-book-swap.js +0 -284
  115. package/dist/strand-peer-book-swap.js.map +0 -1
  116. package/dist/strand-peer-book.d.ts +0 -159
  117. package/dist/strand-peer-book.js +0 -361
  118. package/dist/strand-peer-book.js.map +0 -1
  119. package/dist/strand-peer-observer.d.ts +0 -88
  120. package/dist/strand-peer-observer.js +0 -180
  121. package/dist/strand-peer-observer.js.map +0 -1
  122. package/src/strand-peer-book-file.ts +0 -39
  123. package/src/strand-peer-book-protocol.ts +0 -466
  124. package/src/strand-peer-book-swap.ts +0 -338
  125. package/src/strand-peer-book.ts +0 -475
  126. package/src/strand-peer-observer.ts +0 -235
@@ -200,7 +200,7 @@ export class PushFanoutService {
200
200
  * through to FCM/APNs.
201
201
  *
202
202
  * NOTE: the platform push waits for the whole direct dial to fail, up to
203
- * `DEFAULT_WAKE_DIAL_BUDGET_MS` (46 s at the default declared link). A suspended
203
+ * `DEFAULT_WAKE_DIAL_BUDGET_MS` (57 s at the default declared link). A suspended
204
204
  * phone typically fails its relay address fast and spends the rest on its direct
205
205
  * one. If that delay shows up, start the platform push in parallel with the dial,
206
206
  * or after its first attempt.
@@ -209,7 +209,7 @@ export interface SeedBootstrapConfig {
209
209
  * not-yet-trusted node during onboarding, so this is the more exposed
210
210
  * direction than the receiver knobs above.
211
211
  *
212
- * Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 23 s at the
212
+ * Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 28.5 s at the
213
213
  * default declaration): one dial that may need a relay, then one request and its answer.
214
214
  * Delivery does not set `runOnLimitedConnection`, so it does not use a limited relayed
215
215
  * connection today; the relayed-dial count is the upper bound on the dial it can use, the
@@ -1138,7 +1138,7 @@ export class SeedBootstrapService {
1138
1138
  * stream, then dial the seed's owners.
1139
1139
  *
1140
1140
  * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1141
- * a dial for `dialBudget.totalMs` (64 s at the default declared link), which is not
1141
+ * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1142
1142
  * link time and does not belong inside the sender's delivery deadline. The close is
1143
1143
  * what releases the sender, which reads the ack to end-of-stream. The stream stays
1144
1144
  * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
@@ -387,7 +387,7 @@ export async function collectStrandAddrs(
387
387
  * per-target lines scattered through a concurrent fan-out do not reassemble into
388
388
  * "this sibling was unreachable".
389
389
  *
390
- * NOTE: cost is (targets × `timeoutMs`, 23 s each at the default declared link) with
390
+ * NOTE: cost is (targets × `timeoutMs`, 28.5 s each at the default declared link) with
391
391
  * no whole-sibling budget, the same shape `dialWake` bounds with
392
392
  * `DEFAULT_WAKE_DIAL_BUDGET_MS`. Siblings are asked concurrently, so a collection costs
393
393
  * its slowest sibling, not the sum. Fine today — `dialTargets` yields the peerId plus
@@ -434,7 +434,7 @@ async function dialOneSibling(
434
434
 
435
435
  /**
436
436
  * Deadline for ONE attempt to ask a sibling — dial, request, response — unless the caller set
437
- * its own: `relayedRequestBudgetMs` at the declared link (23 s at the default).
437
+ * its own: `relayedRequestBudgetMs` at the declared link (28.5 s at the default).
438
438
  *
439
439
  * An attempt is either a dial by peer id, which normally reuses an open control connection
440
440
  * (`CIRCUIT_REQUEST_ROUND_TRIPS`, 2), or the fallback to the sibling's control addresses, a
@@ -62,14 +62,14 @@ export interface FormationDeadlines {
62
62
  * cohort read at that link (`cohortReadDeadlineMs`, 2L: one request and its answer over an
63
63
  * open circuit) and `K` one commit (`commitBudgetMs`, 20L, measured):
64
64
  *
65
- * dialMs = a relayed dial plus one protocol negotiation (5L + 2 000)
65
+ * dialMs = a relayed dial plus one protocol negotiation (6L + 4 000)
66
66
  * awaitContactMs = one request over the open stream (2L)
67
67
  * validationMs = 2R (4L)
68
68
  * provisionWorkMs = approval hook (10 000, flat) + 2R + K (10 000 + 24L)
69
69
  * provisionGraceMs = R + K (22L)
70
70
  * responseTravelMarginMs = L + validationMs (5L)
71
71
  * initiatorAwaitResponseMs = work + grace + margin (10 000 + 51L)
72
- * sessionMs = dialMs + initiatorAwaitResponseMs (12 000 + 56L)
72
+ * sessionMs = dialMs + initiatorAwaitResponseMs (14 000 + 57L)
73
73
  *
74
74
  * Why each term:
75
75
  *
@@ -120,10 +120,10 @@ export interface FormationDeadlines {
120
120
  * **What this costs.** At the default declaration (3 500 ms) a joiner whose responder
121
121
  * accepts the contact and then hangs waits up to 188.5 s, about three minutes, before it is
122
122
  * told, where the fixed ladder this replaced told it after 15 s; the whole session is
123
- * bounded at 208 s. A responder that is unreachable still fails at the dial, 19.5 s. A host
123
+ * bounded at 213.5 s. A responder that is unreachable still fails at the dial, 25 s. A host
124
124
  * that knows its party is on a fast link lowers `linkRoundTripMs` and gets roughly the old
125
- * numbers back (at L = 100: dial 2.5 s, provisioning 14.6 s, await-response 15.1 s, session
126
- * 17.6 s). Before this derivation, the fixed 5 s dial could not open a relayed connection
125
+ * numbers back (at L = 100: dial 4.6 s, provisioning 14.6 s, await-response 15.1 s, session
126
+ * 19.7 s). Before this derivation, the fixed 5 s dial could not open a relayed connection
127
127
  * above a 1.25-second round trip, and the fixed 2 s grace could not contain one commit at the
128
128
  * supported link, so a joiner could be told 'timed out' over an invite that was in fact
129
129
  * spent.
@@ -28,12 +28,14 @@ import type {
28
28
  import {
29
29
  FormationListener,
30
30
  INVALID_TOKEN_REASON,
31
- dialFormation,
31
+ dialFormationByMachine,
32
32
  isValidResponderCreatesResult,
33
33
  isWellFormedMembershipInvite,
34
34
  type FormationContactMessage,
35
35
  type FormationProvisionResult,
36
+ type FormationRejection,
36
37
  type FormationResultMessage,
38
+ type FormationTokenCheck,
37
39
  type ResponderProvisionOutcome
38
40
  } from './strand-formation-protocol.js';
39
41
  import { formationDeadlines } from './strand-formation-deadlines.js';
@@ -84,17 +86,18 @@ export const MEMBERSHIP_INVITE_UNAVAILABLE_REASON = 'Strand membership invitatio
84
86
  export const HOST_STRAND_MUST_BE_RECREATED_REASON = 'Host strand must be recreated';
85
87
 
86
88
  /**
87
- * Rejection reason a would-be joiner is told for each approval-failure category
89
+ * The rejection a would-be joiner is told for each approval-failure category
88
90
  * (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
89
- * of these onto the generic 'Formation conflict, retry' would tell an operator to retry
90
- * what retrying can never fix (e.g. a non-enrolled validation key).
91
+ * of these onto the generic `'conflict'` would tell a joiner to retry what retrying can
92
+ * never fix (e.g. a non-enrolled validation key). The three inviter-setup faults share
93
+ * one code; the reason text still names which one for the operator.
91
94
  */
92
- const APPROVAL_REJECTION_REASONS: Record<FormationApprovalFailure, string> = {
93
- refused: 'Formation approval refused',
94
- unavailable: 'Formation approval unavailable, retry',
95
- malformed: 'Formation approval invalid',
96
- unenrolled: 'Formation approval key is not enrolled',
97
- misconfigured: 'Formation approval misconfigured'
95
+ const APPROVAL_REJECTIONS: Record<FormationApprovalFailure, FormationRejection> = {
96
+ refused: { approved: false, code: 'approval-refused', reason: 'Formation approval refused' },
97
+ unavailable: { approved: false, code: 'approval-unavailable', reason: 'Formation approval unavailable, retry' },
98
+ malformed: { approved: false, code: 'approval-invalid', reason: 'Formation approval invalid' },
99
+ unenrolled: { approved: false, code: 'approval-invalid', reason: 'Formation approval key is not enrolled' },
100
+ misconfigured: { approved: false, code: 'approval-invalid', reason: 'Formation approval misconfigured' }
98
101
  };
99
102
 
100
103
  /**
@@ -138,6 +141,13 @@ export interface StrandFormationManagerConfig {
138
141
  protocolId?: string;
139
142
  }
140
143
 
144
+ /**
145
+ * A node's own cadre addresses, sent on every formation. A function is read at each
146
+ * formation, for a node whose addresses change after the manager is built — a
147
+ * `CadreNode` installs its responder before a relay reservation gives it any.
148
+ */
149
+ export type CadrePeerAddrsSource = string[] | (() => string[]);
150
+
141
151
  /**
142
152
  * Options for creating a StrandFormationManager
143
153
  */
@@ -153,7 +163,7 @@ export interface StrandFormationManagerOptions {
153
163
  /** This party's ID for identification */
154
164
  partyId: string;
155
165
  /** This party's cadre peer addresses */
156
- cadrePeerAddrs?: string[];
166
+ cadrePeerAddrs?: CadrePeerAddrsSource;
157
167
  /**
158
168
  * This node's live STRAND-network multiaddrs for a strand it is running (responder
159
169
  * side). Wired by `CadreNode` to its own per-strand address lookup; the manager has no
@@ -213,7 +223,7 @@ export class StrandFormationManager {
213
223
  private readonly strandProvisioner?: StrandProvisioner;
214
224
  private readonly formationResponseValidator?: FormationResponseValidator;
215
225
  private readonly partyId: string;
216
- private readonly cadrePeerAddrs: string[];
226
+ private readonly cadrePeerAddrs: CadrePeerAddrsSource;
217
227
  private readonly resolveStrandAddrs?: (strandId: string) => string[];
218
228
  private readonly issueMembershipInvite?: MembershipInviteIssuer;
219
229
  private readonly config: StrandFormationManagerConfig;
@@ -237,7 +247,7 @@ export class StrandFormationManager {
237
247
  validateDisclosure: (token, disclosure) => this.validateDisclosure(token, disclosure),
238
248
  provisionStrand: (contact, signal) =>
239
249
  this.provisionAsResponder(contact, signal),
240
- getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.cadrePeerAddrs }),
250
+ getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.currentCadrePeerAddrs() }),
241
251
  // Forwarded only when wired, so `FormationListenerOptions.resolveStrandAddrs`
242
252
  // stays genuinely absent (and the listener short-circuits) for an unwired manager.
243
253
  ...(this.resolveStrandAddrs && { resolveStrandAddrs: this.resolveStrandAddrs }),
@@ -251,6 +261,10 @@ export class StrandFormationManager {
251
261
  log('StrandFormationManager created for party: %s', this.partyId);
252
262
  }
253
263
 
264
+ private currentCadrePeerAddrs(): string[] {
265
+ return typeof this.cadrePeerAddrs === 'function' ? this.cadrePeerAddrs() : this.cadrePeerAddrs;
266
+ }
267
+
254
268
  /**
255
269
  * Register this manager as a protocol handler on a libp2p node.
256
270
  * Call this on the control network node to handle incoming formation requests.
@@ -283,8 +297,10 @@ export class StrandFormationManager {
283
297
  * Builds a contact message carrying the real token + disclosure + this party's
284
298
  * real cadre addresses + the joiner's consent (minted and signed by the
285
299
  * solicitation layer — this manager only places it on the contact), dials the
286
- * responder over the native protocol, and validates the responder's result
287
- * before returning.
300
+ * inviting party's machines the invitation names, one at a time, over the native
301
+ * protocol ({@link dialFormationByMachine}), and validates the responder's result
302
+ * before returning. Every machine gets the same contact, so at most one of them can
303
+ * record this redemption.
288
304
  */
289
305
  async formStrand(
290
306
  invitation: OpenInvitation,
@@ -301,12 +317,12 @@ export class StrandFormationManager {
301
317
  usageStampId: consent.usageStampId,
302
318
  peerSignature: consent.peerSignature,
303
319
  disclosure,
304
- cadrePeerAddrs: this.cadrePeerAddrs
320
+ cadrePeerAddrs: this.currentCadrePeerAddrs()
305
321
  };
306
322
 
307
323
  this.dialerSessions++;
308
324
  try {
309
- const dialed = await dialFormation(node, {
325
+ const dialed = await dialFormationByMachine(node, {
310
326
  contact,
311
327
  responderAddrs: invitation.bootstrap,
312
328
  validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
@@ -383,7 +399,12 @@ export class StrandFormationManager {
383
399
 
384
400
  // ── Responder-side hooks ─────────────────────────────────────────────────────
385
401
 
386
- private async validateToken(token: string): Promise<{ valid: boolean }> {
402
+ /**
403
+ * An expired or used-up invitation is `'token-spent'` (final); one with no row here is
404
+ * `'token-unknown'` (retryable — the row may not have replicated to this machine yet), and
405
+ * so is one from a recorder that does not say why.
406
+ */
407
+ private async validateToken(token: string): Promise<FormationTokenCheck> {
387
408
  if (!this.formationUsageRecorder) {
388
409
  // No recorder configured — accept all tokens.
389
410
  return { valid: true };
@@ -391,13 +412,13 @@ export class StrandFormationManager {
391
412
 
392
413
  const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
393
414
  if (!tokenCheck.valid) {
394
- log('Token invalid: %s', token);
395
- return { valid: false };
415
+ log('Token invalid (%s): %s', tokenCheck.reason ?? 'unknown', token);
416
+ return { valid: false, code: tokenCheck.reason === 'expired' ? 'token-spent' : 'token-unknown' };
396
417
  }
397
418
 
398
419
  if (await this.formationUsageRecorder.isTokenUsed(token)) {
399
420
  log('Token already used: %s', token);
400
- return { valid: false };
421
+ return { valid: false, code: 'token-spent' };
401
422
  }
402
423
 
403
424
  return { valid: true };
@@ -438,7 +459,7 @@ export class StrandFormationManager {
438
459
  * ({@link InvitationExhaustedError}, raised by `ControlDatabase` when the count of recorded
439
460
  * uses has reached the invite's seat budget — which is how the loser of a same-node race
440
461
  * surfaces, the local write queue having serialized the two writes) is reported as
441
- * `'Invalid token'`, because retrying it can never succeed. Concurrent redemptions never
462
+ * `'token-spent'`, because retrying it can never succeed. Concurrent redemptions never
442
463
  * contend for a shared row key — each writes under its own `UsageStampId` — so there is no
443
464
  * key collision to surface here at all. The LOG-before-reject keeps this a deliberate
444
465
  * internal-error→protocol-rejection conversion (AGENTS.md: don't eat exceptions silently),
@@ -456,7 +477,7 @@ export class StrandFormationManager {
456
477
  const disclosureText = canonicalJson(contact.disclosure);
457
478
  if (uint8ArrayFromString(disclosureText, 'utf8').byteLength > MAX_DISCLOSURE_BYTES) {
458
479
  log('Disclosure over %d bytes; rejecting token %s', MAX_DISCLOSURE_BYTES, token);
459
- return { approved: false, reason: 'Disclosure too large' };
480
+ return { approved: false, code: 'disclosure-too-large', reason: 'Disclosure too large' };
460
481
  }
461
482
 
462
483
  const recorder = this.formationUsageRecorder;
@@ -487,7 +508,7 @@ export class StrandFormationManager {
487
508
  // here can atomically un-issue a strand-DB row.
488
509
  const issued = await this.issueBoundMembershipInvite(token, resolved.strandId, signal);
489
510
  if (!issued.ok) {
490
- return { approved: false, reason: issued.reason };
511
+ return issued.rejection;
491
512
  }
492
513
  await authorized.record();
493
514
  return this.approve({
@@ -501,7 +522,7 @@ export class StrandFormationManager {
501
522
  }
502
523
  case 'missing': {
503
524
  log('Host strand %s not yet available on this responder; rejecting token %s', resolved.strandId, token);
504
- return { approved: false, reason: 'Host strand not yet available on this responder' };
525
+ return { approved: false, code: 'host-strand-unavailable', reason: 'Host strand not yet available on this responder' };
505
526
  }
506
527
  case 'unbound':
507
528
  return await this.provisionUnbound(contact, disclosureText, signal);
@@ -514,29 +535,27 @@ export class StrandFormationManager {
514
535
  }
515
536
  if (err instanceof FormationApprovalError) {
516
537
  log('approval failed (%s) for token %s: %o', err.failure, token, err);
517
- return { approved: false, reason: APPROVAL_REJECTION_REASONS[err.failure] };
538
+ return APPROVAL_REJECTIONS[err.failure];
518
539
  }
519
540
  if (err instanceof InvitationExhaustedError) {
520
- // Shares `INVALID_TOKEN_REASON` with the up-front `validateToken` rejection so the loser
521
- // of the race that exposed the spent invite sees exactly what a non-racing latecomer
522
- // sees. No wire-visible distinction between "invalid" and "exhausted" — the
523
- // operator signal lives in this log line instead.
524
- // NOTE: if a joining client ever has to tell "never valid" from "used up" WITHOUT node
525
- // logs, that is a new protocol reason string, not a local change here.
541
+ // Shares `INVALID_TOKEN_REASON` and `'token-spent'` with the up-front `validateToken`
542
+ // rejection so the loser of the race that exposed the spent invite sees exactly what a
543
+ // non-racing latecomer sees. How many uses were recorded against the total is for the
544
+ // operator, in this log line, not for the joiner.
526
545
  log(
527
546
  'invitation exhausted for token %s: %d of %d use(s) already recorded',
528
547
  err.token,
529
548
  err.usesRecorded,
530
549
  err.totalUses
531
550
  );
532
- return { approved: false, reason: INVALID_TOKEN_REASON };
551
+ return { approved: false, code: 'token-spent', reason: INVALID_TOKEN_REASON };
533
552
  }
534
553
  // An approval is never discarded to a lost key race: each redemption writes under its
535
554
  // own `UsageStampId`, so no other writer can take its row key. Reaching this catch-all
536
555
  // means the failure was never retryable at the database layer to begin with (or the
537
556
  // transient-cluster retry inside `ControlDatabase.lockedWithRetry` ran out).
538
557
  log('provisionAsResponder failed for token %s: %o', token, err);
539
- return { approved: false, reason: 'Formation conflict, retry' };
558
+ return { approved: false, code: 'conflict', reason: 'Formation conflict, retry' };
540
559
  }
541
560
  }
542
561
 
@@ -579,7 +598,7 @@ export class StrandFormationManager {
579
598
  token: string,
580
599
  strandId: string,
581
600
  signal?: AbortSignal
582
- ): Promise<{ ok: true; invite?: StrandMembershipInvite } | { ok: false; reason: string }> {
601
+ ): Promise<{ ok: true; invite?: StrandMembershipInvite } | { ok: false; rejection: FormationRejection }> {
583
602
  if (!this.issueMembershipInvite) {
584
603
  return { ok: true };
585
604
  }
@@ -589,10 +608,16 @@ export class StrandFormationManager {
589
608
  } catch (err) {
590
609
  if (err instanceof PreSplitStrandIdentityError) {
591
610
  log('host strand %s is pre-split (token %s); rejecting — it must be recreated: %o', strandId, token, err);
592
- return { ok: false, reason: HOST_STRAND_MUST_BE_RECREATED_REASON };
611
+ return {
612
+ ok: false,
613
+ rejection: { approved: false, code: 'host-strand-must-be-recreated', reason: HOST_STRAND_MUST_BE_RECREATED_REASON }
614
+ };
593
615
  }
594
616
  log('membership-invite issue for strand %s failed (token %s); rejecting retryably: %o', strandId, token, err);
595
- return { ok: false, reason: MEMBERSHIP_INVITE_UNAVAILABLE_REASON };
617
+ return {
618
+ ok: false,
619
+ rejection: { approved: false, code: 'host-strand-unavailable', reason: MEMBERSHIP_INVITE_UNAVAILABLE_REASON }
620
+ };
596
621
  }
597
622
  }
598
623