@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -4,7 +4,8 @@ import type { Libp2p } from '@libp2p/interface';
4
4
  import type {
5
5
  OpenInvitation,
6
6
  FormStrandResult,
7
- StrandFormationDisclosure
7
+ StrandFormationDisclosure,
8
+ StrandMembershipInvite
8
9
  } from './types.js';
9
10
  import {
10
11
  FormationApprovalError,
@@ -12,6 +13,7 @@ import {
12
13
  } from './formation-approval.js';
13
14
  // control-database does not import this manager, so this import introduces no cycle.
14
15
  import { FormationAbortedError, InvitationExhaustedError } from './control-database.js';
16
+ import { PreSplitStrandIdentityError } from './strand-membership-writer.js';
15
17
  import { canonicalJson } from './canonical-json.js';
16
18
  import type {
17
19
  DisclosureValidator,
@@ -26,6 +28,7 @@ import {
26
28
  PROVISION_RESPONSE_TRAVEL_MARGIN_MS,
27
29
  dialFormation,
28
30
  isValidResponderCreatesResult,
31
+ isWellFormedMembershipInvite,
29
32
  type FormationContactMessage,
30
33
  type FormationProvisionResult,
31
34
  type FormationResultMessage,
@@ -42,6 +45,39 @@ const log = debug('sereus:cadre:formation-manager');
42
45
  */
43
46
  const MAX_DISCLOSURE_BYTES = 8 * 1024;
44
47
 
48
+ /**
49
+ * How long a formation-issued strand membership invitation stays redeemable (7 days).
50
+ * The responder's {@link StrandFormationManagerOptions.issueMembershipInvite} hook stamps
51
+ * `now + this` as the `Strand.Invite.Expiration`. Long enough for a slow joiner to bring
52
+ * its strand node up and redeem; short enough that a lost formation result does not leave
53
+ * a live bearer credential in the strand forever. An expired invitation rolls the
54
+ * redemption back cleanly (the schema's `NotExpired` gate) — the joiner's recovery is a
55
+ * fresh formation, which issues a fresh invitation.
56
+ */
57
+ export const MEMBERSHIP_INVITE_TTL_MS = 7 * 24 * 3600_000;
58
+
59
+ /**
60
+ * Rejection reason for a bound CLOSED-strand redemption whose responder could not issue
61
+ * the joiner's membership invitation — the host strand runtime not live on this
62
+ * responder, no party identity key yet, or the strand DB refusing the `Strand.Invite`
63
+ * write. Retryable on purpose, and rejected BEFORE the consent row is recorded, so the
64
+ * formation token stays unspent: approving without an invitation would admit a joiner
65
+ * that looks joined but can never become a member, and a responder not running the
66
+ * strand cannot serve the joiner's sync anyway. The one permanent failure,
67
+ * a pre-split host strand, gets {@link HOST_STRAND_MUST_BE_RECREATED_REASON} instead.
68
+ */
69
+ export const MEMBERSHIP_INVITE_UNAVAILABLE_REASON = 'Strand membership invitation unavailable, retry';
70
+
71
+ /**
72
+ * Rejection reason for a bound CLOSED-strand redemption whose host strand was founded
73
+ * before the per-party identity split: the responder's founder launch refused it
74
+ * (`PreSplitStrandIdentityError`), so no invitation can ever be issued into it. Not
75
+ * retryable — the host must recreate the strand and issue a fresh invitation. Like
76
+ * {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON}, rejected before consent is recorded, so
77
+ * the formation token stays unspent.
78
+ */
79
+ export const HOST_STRAND_MUST_BE_RECREATED_REASON = 'Host strand must be recreated';
80
+
45
81
  /**
46
82
  * Rejection reason a would-be joiner is told for each approval-failure category
47
83
  * (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
@@ -96,6 +132,38 @@ export interface StrandFormationManagerOptions {
96
132
  partyId: string;
97
133
  /** This party's cadre peer addresses */
98
134
  cadrePeerAddrs?: string[];
135
+ /**
136
+ * This node's live STRAND-network multiaddrs for a strand it is running (responder
137
+ * side). Wired by `CadreNode` to its own per-strand address lookup; the manager has no
138
+ * route to the strand runtime itself.
139
+ *
140
+ * A hook, not a `config` knob, so it sits with the other responder-side seams
141
+ * (`strandProvisioner`, `formationUsageRecorder`) rather than among the timeouts. Left
142
+ * unwired — every mock/transport test — the responder simply discloses no strand
143
+ * addresses and cross-party joiners fall back to an empty seed.
144
+ */
145
+ resolveStrandAddrs?: (strandId: string) => string[];
146
+ /**
147
+ * Issue a single-use `Strand.Invite` against the live host strand a BOUND redemption
148
+ * resolved to (responder side) — the seam that gets the JOINER its own membership.
149
+ * Like {@link resolveStrandAddrs}, the formation layer cannot reach the strand
150
+ * runtime itself, so `CadreNode` wires this to its own running-instance +
151
+ * `StrandPartyKey` lookup.
152
+ *
153
+ * Contract: return the issued invitation for a CLOSED host strand; return `null`
154
+ * for an open one (no members, nothing to invite into); THROW when a closed strand's
155
+ * invitation cannot be issued (runtime not live, no party key, strand DB write
156
+ * rejected) — the manager then rejects the whole redemption with
157
+ * {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} BEFORE recording consent, so the
158
+ * formation token stays unspent and the joiner can retry. Throw a
159
+ * `PreSplitStrandIdentityError` for a host strand that can never issue one; that maps
160
+ * to the non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
161
+ *
162
+ * Left unwired — mock/transport tests — the bound path approves with no invitation,
163
+ * mirroring the unwired {@link resolveStrandAddrs} posture. Production
164
+ * (`CadreNode.initializeStrandSolicitation`) always wires it.
165
+ */
166
+ issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
99
167
  /** Configuration options */
100
168
  config?: StrandFormationManagerConfig;
101
169
  }
@@ -120,6 +188,8 @@ export class StrandFormationManager {
120
188
  private readonly formationResponseValidator?: FormationResponseValidator;
121
189
  private readonly partyId: string;
122
190
  private readonly cadrePeerAddrs: string[];
191
+ private readonly resolveStrandAddrs?: (strandId: string) => string[];
192
+ private readonly issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
123
193
  private readonly config: StrandFormationManagerConfig;
124
194
  private readonly listener: FormationListener;
125
195
  private readonly registeredNodes = new Set<Libp2p>();
@@ -132,6 +202,8 @@ export class StrandFormationManager {
132
202
  this.formationResponseValidator = options.formationResponseValidator;
133
203
  this.partyId = options.partyId;
134
204
  this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
205
+ this.resolveStrandAddrs = options.resolveStrandAddrs;
206
+ this.issueMembershipInvite = options.issueMembershipInvite;
135
207
  this.config = options.config ?? {};
136
208
 
137
209
  this.listener = new FormationListener({
@@ -140,6 +212,9 @@ export class StrandFormationManager {
140
212
  provisionStrand: (contact, signal) =>
141
213
  this.provisionAsResponder(contact, signal),
142
214
  getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.cadrePeerAddrs }),
215
+ // Forwarded only when wired, so `FormationListenerOptions.resolveStrandAddrs`
216
+ // stays genuinely absent (and the listener short-circuits) for an unwired manager.
217
+ ...(this.resolveStrandAddrs && { resolveStrandAddrs: this.resolveStrandAddrs }),
143
218
  sessionTimeoutMs: this.config.sessionTimeoutMs,
144
219
  stepTimeoutMs: this.config.stepTimeoutMs,
145
220
  provisionTimeoutMs: this.config.provisionTimeoutMs,
@@ -204,7 +279,7 @@ export class StrandFormationManager {
204
279
 
205
280
  this.dialerSessions++;
206
281
  try {
207
- const provision = await dialFormation(node, {
282
+ const dialed = await dialFormation(node, {
208
283
  contact,
209
284
  responderAddrs: invitation.bootstrap,
210
285
  validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
@@ -214,7 +289,8 @@ export class StrandFormationManager {
214
289
  protocolId: this.config.protocolId
215
290
  });
216
291
 
217
- log('Strand formed: %s', provision.strand.strandId);
292
+ const provision = dialed.provision;
293
+ log('Strand formed: %s (%d cross-party strand addr(s))', provision.strand.strandId, dialed.strandAddrs.length);
218
294
 
219
295
  return {
220
296
  memberKey: contact.partyId,
@@ -223,7 +299,13 @@ export class StrandFormationManager {
223
299
  // The host strand's membership key, delivered through the protocol (provision-then-record).
224
300
  // Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
225
301
  // generated signing key), which is set by the StrandSolicitationService layer.
226
- memberPrivateKey: provision.memberPrivateKey
302
+ memberPrivateKey: provision.memberPrivateKey,
303
+ // Carried only when well-formed. The DEFAULT response validator already rejects
304
+ // a malformed one; this re-check keeps a custom validator that skips the shape
305
+ // check from feeding a hostile responder's object into the joiner's invite cache.
306
+ membershipInvite: this.membershipInviteFrom(provision),
307
+ // Possibly empty — the responder may hold no live strand node yet.
308
+ strandAddrs: dialed.strandAddrs
227
309
  };
228
310
  } finally {
229
311
  this.dialerSessions--;
@@ -237,6 +319,23 @@ export class StrandFormationManager {
237
319
  return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
238
320
  }
239
321
 
322
+ /**
323
+ * The provision result's membership invitation, iff well-formed — a malformed one
324
+ * (possible only past a custom {@link FormationResponseValidator} that skipped the
325
+ * structural check) is dropped with a log rather than cached: it could never redeem
326
+ * anyway, and carrying an unbounded hostile object outlives the session.
327
+ */
328
+ private membershipInviteFrom(provision: FormationProvisionResult): StrandMembershipInvite | undefined {
329
+ if (provision.membershipInvite === undefined) {
330
+ return undefined;
331
+ }
332
+ if (!isWellFormedMembershipInvite(provision.membershipInvite)) {
333
+ log('formStrand: dropping malformed membershipInvite from responder for strand %s', provision.strand.strandId);
334
+ return undefined;
335
+ }
336
+ return { inviteKey: provision.membershipInvite.inviteKey, invitePrivateKey: provision.membershipInvite.invitePrivateKey };
337
+ }
338
+
240
339
  /**
241
340
  * Derive the initiator's await-response budget from the configured RESPONDER budget —
242
341
  * never the same number (see `StrandFormationManagerConfig.provisionTimeoutMs`). Mirrors
@@ -287,10 +386,14 @@ export class StrandFormationManager {
287
386
  * {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
288
387
  * into a clean, non-disclosing reply instead of a dropped result frame.
289
388
  *
290
- * - **bound** (host strand present): provision-then-record — write the single
291
- * `FormationUsage` consent row against the pre-existing strand (record-only) and
292
- * return it + its membership key (a read-gating secret disclosed only here, behind the
293
- * token + disclosure validation `runSession` already enforced).
389
+ * - **bound** (host strand present): provision-then-record — first issue the joiner's
390
+ * single-use strand membership invitation via the wired `issueMembershipInvite` seam
391
+ * ({@link issueBoundMembershipInvite}; a closed strand whose invitation cannot be
392
+ * issued rejects retryably HERE, before any consent row spends the token), then write
393
+ * the single `FormationUsage` consent row against the pre-existing strand
394
+ * (record-only) and return it + its membership key + the invitation (all read-gating
395
+ * secrets disclosed only here, behind the token + disclosure validation `runSession`
396
+ * already enforced).
294
397
  * - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
295
398
  * reject cleanly + retryably, writing NO usage row — recording usage here would fail the
296
399
  * deferred `StrandExists` CHECK at commit and drop the frame.
@@ -331,6 +434,16 @@ export class StrandFormationManager {
331
434
  try {
332
435
  switch (resolved.kind) {
333
436
  case 'bound': {
437
+ // Issued BEFORE the consent row is recorded, so an issue failure rejects with
438
+ // the formation token still unspent (retryable). The inverse window — invite
439
+ // issued, then recordUsage fails/aborts — orphans a live `Strand.Invite` no
440
+ // joiner ever received; bounded deliberately by its expiry
441
+ // ({@link MEMBERSHIP_INVITE_TTL_MS}) rather than compensated, since nothing
442
+ // here can atomically un-issue a strand-DB row.
443
+ const issued = await this.issueBoundMembershipInvite(token, resolved.strandId);
444
+ if (!issued.ok) {
445
+ return { approved: false, reason: issued.reason };
446
+ }
334
447
  // recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
335
448
  await recorder!.recordUsage({
336
449
  token,
@@ -344,6 +457,9 @@ export class StrandFormationManager {
344
457
  return this.approve({
345
458
  strand: { strandId: resolved.strandId, createdBy: 'responder' },
346
459
  memberPrivateKey: resolved.memberPrivateKey ?? undefined,
460
+ // Omitted (not sent undefined-valued) so rejection-parity and open-strand
461
+ // assertions stay plain `toBeUndefined()` on the parsed frame.
462
+ ...(issued.invite ? { membershipInvite: issued.invite } : {}),
347
463
  dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
348
464
  });
349
465
  }
@@ -388,6 +504,44 @@ export class StrandFormationManager {
388
504
  }
389
505
  }
390
506
 
507
+ /**
508
+ * Issue the joiner's membership invitation for a BOUND redemption via the wired
509
+ * {@link StrandFormationManagerOptions.issueMembershipInvite} seam.
510
+ *
511
+ * - Hook unwired (mock/transport tests): approve with no invitation, the same posture
512
+ * as an unwired `resolveStrandAddrs`.
513
+ * - Hook returns an invitation (closed host strand): carry it on the approval.
514
+ * - Hook returns `null` (open host strand): approve with no invitation.
515
+ * - Hook throws (runtime not live, no party key, strand-DB write rejected): report
516
+ * `ok: false` with {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} — the caller rejects
517
+ * BEFORE any consent row is written, so the formation token stays unspent.
518
+ * - Hook throws `PreSplitStrandIdentityError` (the host strand was founded before the
519
+ * per-party identity split and can never issue one): same, but with the
520
+ * non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
521
+ *
522
+ * The LOG-before-reject keeps both a deliberate internal-error→protocol-rejection
523
+ * conversion, same as the catch-all in {@link provisionAsResponder}.
524
+ */
525
+ private async issueBoundMembershipInvite(
526
+ token: string,
527
+ strandId: string
528
+ ): Promise<{ ok: true; invite?: StrandMembershipInvite } | { ok: false; reason: string }> {
529
+ if (!this.issueMembershipInvite) {
530
+ return { ok: true };
531
+ }
532
+ try {
533
+ const invite = await this.issueMembershipInvite(strandId);
534
+ return { ok: true, invite: invite ?? undefined };
535
+ } catch (err) {
536
+ if (err instanceof PreSplitStrandIdentityError) {
537
+ log('host strand %s is pre-split (token %s); rejecting — it must be recreated: %o', strandId, token, err);
538
+ return { ok: false, reason: HOST_STRAND_MUST_BE_RECREATED_REASON };
539
+ }
540
+ log('membership-invite issue for strand %s failed (token %s); rejecting retryably: %o', strandId, token, err);
541
+ return { ok: false, reason: MEMBERSHIP_INVITE_UNAVAILABLE_REASON };
542
+ }
543
+ }
544
+
391
545
  /**
392
546
  * Responder-provisions fallback (invite binds no host strand). Precedence:
393
547
  *