@serfab/cadre-core 0.12.0 → 0.13.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 (86) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-node.d.ts +328 -25
  3. package/dist/cadre-node.js +760 -59
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +19 -0
  6. package/dist/control-database.js +51 -9
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-schema.d.ts +1 -1
  9. package/dist/control-schema.js +30 -2
  10. package/dist/control-schema.js.map +1 -1
  11. package/dist/enrolled-machine-store-file.d.ts +13 -0
  12. package/dist/enrolled-machine-store-file.js +41 -0
  13. package/dist/enrolled-machine-store-file.js.map +1 -0
  14. package/dist/enrolled-machine-store.d.ts +81 -0
  15. package/dist/enrolled-machine-store.js +225 -0
  16. package/dist/enrolled-machine-store.js.map +1 -0
  17. package/dist/file-durable-slot.js +5 -3
  18. package/dist/file-durable-slot.js.map +1 -1
  19. package/dist/index.d.ts +5 -3
  20. package/dist/index.js +23 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/membership-connection-gater.d.ts +15 -1
  23. package/dist/membership-connection-gater.js +9 -3
  24. package/dist/membership-connection-gater.js.map +1 -1
  25. package/dist/node-local-snapshot.d.ts +6 -0
  26. package/dist/node-local-snapshot.js +7 -1
  27. package/dist/node-local-snapshot.js.map +1 -1
  28. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  29. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  30. package/dist/peer-join-backfill.js.map +1 -0
  31. package/dist/relay-addrs.d.ts +73 -0
  32. package/dist/relay-addrs.js +195 -0
  33. package/dist/relay-addrs.js.map +1 -1
  34. package/dist/strand-database.d.ts +12 -0
  35. package/dist/strand-database.js +16 -0
  36. package/dist/strand-database.js.map +1 -1
  37. package/dist/strand-formation-manager.d.ts +12 -0
  38. package/dist/strand-formation-manager.js +10 -3
  39. package/dist/strand-formation-manager.js.map +1 -1
  40. package/dist/strand-formation-protocol.d.ts +76 -2
  41. package/dist/strand-formation-protocol.js +78 -2
  42. package/dist/strand-formation-protocol.js.map +1 -1
  43. package/dist/strand-instance-manager.d.ts +147 -14
  44. package/dist/strand-instance-manager.js +225 -45
  45. package/dist/strand-instance-manager.js.map +1 -1
  46. package/dist/strand-membership-writer.d.ts +12 -0
  47. package/dist/strand-membership-writer.js +23 -1
  48. package/dist/strand-membership-writer.js.map +1 -1
  49. package/dist/strand-network-config.d.ts +84 -0
  50. package/dist/strand-network-config.js +136 -0
  51. package/dist/strand-network-config.js.map +1 -0
  52. package/dist/strand-revocation-enforcer.d.ts +392 -0
  53. package/dist/strand-revocation-enforcer.js +533 -0
  54. package/dist/strand-revocation-enforcer.js.map +1 -0
  55. package/dist/strand-solicitation.d.ts +7 -0
  56. package/dist/strand-solicitation.js +8 -2
  57. package/dist/strand-solicitation.js.map +1 -1
  58. package/dist/strand-watcher.js +6 -1
  59. package/dist/strand-watcher.js.map +1 -1
  60. package/dist/types.d.ts +210 -16
  61. package/dist/types.js +7 -1
  62. package/dist/types.js.map +1 -1
  63. package/package.json +16 -8
  64. package/src/cadre-node.ts +6353 -5603
  65. package/src/control-database.ts +53 -9
  66. package/src/control-schema.ts +30 -2
  67. package/src/enrolled-machine-store-file.ts +43 -0
  68. package/src/enrolled-machine-store.ts +280 -0
  69. package/src/file-durable-slot.ts +5 -3
  70. package/src/index.ts +52 -10
  71. package/src/membership-connection-gater.ts +9 -3
  72. package/src/node-local-snapshot.ts +13 -1
  73. package/src/peer-addr-book.ts +251 -250
  74. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  75. package/src/relay-addrs.ts +218 -0
  76. package/src/strand-database.ts +17 -0
  77. package/src/strand-formation-manager.ts +22 -3
  78. package/src/strand-formation-protocol.ts +808 -692
  79. package/src/strand-instance-manager.ts +317 -54
  80. package/src/strand-membership-writer.ts +23 -1
  81. package/src/strand-network-config.ts +172 -0
  82. package/src/strand-revocation-enforcer.ts +653 -0
  83. package/src/strand-solicitation.ts +484 -471
  84. package/src/strand-watcher.ts +319 -314
  85. package/src/types.ts +1554 -1354
  86. package/dist/strand-backfill.js.map +0 -1
package/README.md CHANGED
@@ -168,9 +168,10 @@ The main entry point for cadre participation.
168
168
  | `stop()` | Gracefully disconnect from all networks |
169
169
  | `getStrands()` | Get all active strand instances |
170
170
  | `getStrand(id)` | Get a specific strand instance |
171
- | `addStrand(row)` | Manually add a strand (testing/direct API) |
171
+ | `foundStrand(config)` | **Create a strand**: publish its `Strand` row and start it locally as founder, in one call that is safe to re-run after an interruption. Returns the instance plus the row it actually runs under — for a closed strand read the membership key from there, since a resumed founding keeps the stored key |
172
+ | `addStrand(row)` | Attach only — start a local instance for a row that already exists (the join path; also testing/direct API). Does **not** publish |
172
173
  | `stopStrand(id)` | Stop a strand on this node only (the shared `Strand` row stays; rediscovered on restart) |
173
- | `publishStrand(id, type?, memberKey?)` | Owner-signed `Strand` row insert — makes the strand visible cadre-wide |
174
+ | `publishStrand(id, type?, memberKey?)` | Owner-signed `Strand` row insert — makes the strand visible cadre-wide. Idempotent: a live row with the same `(Type, MemberPrivateKey)` is returned unwritten; different content on the same id throws. Prefer `foundStrand` when creating a strand |
174
175
  | `unpublishStrand(id)` | Owner-signed party-wide removal — deletes this party's `Strand` row; every node stops the strand. Destroys a closed strand's `MemberPrivateKey` |
175
176
  | `getEnrollmentService()` | Access peer enrollment API |
176
177
 
@@ -1,9 +1,10 @@
1
1
  import type { Libp2p, PeerId } from '@libp2p/interface';
2
2
  import type { Multiaddr } from '@multiformats/multiaddr';
3
- import type { CadreNodeConfig, StrandInstance, StrandConfig, SAppConfig, CadreNodeEvents, ControlNetworkSeed, ApplySeedResult, AddDroneOptions, AddPhoneOptions, DroneInitResult, InviteResult, CadreInvite, OpenInvitation, FormStrandResult, StrandFormationDisclosure, ResolveOpts, SelfRegistrationOutcome, ServiceWakeResult, PushPlatform, DeviceTokenRecord, ResolveDeviceTokenOpts } from './types.js';
3
+ import type { CadreNodeConfig, StrandInstance, StrandRow, StrandConfig, FoundStrandConfig, FoundStrandResult, SAppConfig, CadreNodeEvents, ControlNetworkSeed, ApplySeedResult, AddDroneOptions, AddPhoneOptions, DroneInitResult, InviteResult, CadreInvite, OpenInvitation, FormStrandResult, StrandFormationDisclosure, ResolveOpts, SelfRegistrationOutcome, ServiceWakeResult, PushPlatform, DeviceTokenRecord, ResolveDeviceTokenOpts } from './types.js';
4
4
  import { type Ed25519KeyPair } from './ed25519-key.js';
5
5
  import { type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
6
6
  import { type BootstrapPeerStore } from './bootstrap-peer-store.js';
7
+ import { type EnrolledMachineStore } from './enrolled-machine-store.js';
7
8
  import { type SAppIdLookup } from './strand-watcher.js';
8
9
  import { EnrollmentService } from './enrollment.js';
9
10
  import { ControlDatabase } from './control-database.js';
@@ -57,6 +58,14 @@ export declare class CadreNode implements SAppIdLookup {
57
58
  private trustedOwnerStore;
58
59
  private controlNode;
59
60
  private controlDatabase;
61
+ /**
62
+ * The CONTROL network's peer-join block catch-up — same module as the
63
+ * per-strand ones, but membership-gated (see {@link startControlBackfill}).
64
+ * Created in {@link start} after the control node and database are up, stopped
65
+ * and dropped in {@link cleanup} before either is torn down, so a stop()→
66
+ * start() cycle rebuilds it with a fresh caught-up-peer memo.
67
+ */
68
+ private controlBackfill;
60
69
  private strandWatcher;
61
70
  private strandManager;
62
71
  private hibernationManager;
@@ -237,6 +246,36 @@ export declare class CadreNode implements SAppIdLookup {
237
246
  * stale stamp.
238
247
  */
239
248
  private readonly strandPeerAddrRefreshAt;
249
+ /**
250
+ * CROSS-PARTY strand-network addresses learned at formation, keyed by strandId — the
251
+ * responder's live strand addrs, carried back on the formation result
252
+ * (`FormationResultMessage.strandAddrs`) and recorded by {@link formStrand}.
253
+ *
254
+ * This is the ONLY cross-party discovery input there is. The strand-addr RPC that
255
+ * resolves a strand's addresses is membership-gated and answers own-party siblings
256
+ * only, so without this map a joiner's cohort seed for a two-party strand is empty and
257
+ * the mesh never forms.
258
+ *
259
+ * Read by {@link resolveCohortSeed} (launch + hibernation resume) and re-merged by
260
+ * every {@link refreshStrandPeerAddrs} pass — the re-merge is what keeps the entries
261
+ * alive past the peerStore's one-hour address expiry, since nothing can re-resolve
262
+ * them.
263
+ *
264
+ * IN-MEMORY and ONE-SHOT, deliberately (see `docs/strands.md`): the addresses die with
265
+ * the process, and a restarted initiator with no sibling running the strand is back to
266
+ * an empty seed until the durability work lands
267
+ * (`backlog/feat-cross-party-strand-addr-durability`). They are also never refreshed,
268
+ * so a responder whose relay reservation rotates before the initiator dials leaves a
269
+ * dead entry behind; recovery today is a fresh invitation.
270
+ *
271
+ * NOTE: entries are kept for the node's lifetime, including across a
272
+ * {@link stopStrand} — a stopped strand may be rediscovered by the watcher and must
273
+ * still get its seed. Each strand's list is capped at `MAX_STRAND_ADDRS`, but the
274
+ * number of KEYS is bounded only by the strands this node has ever formed as an
275
+ * initiator (not by time); if a node ever forms strands at scale, evict on
276
+ * `unpublishStrand` or cap the map.
277
+ */
278
+ private readonly crossPartyStrandAddrs;
240
279
  /**
241
280
  * Cold-start bootstrap dial targets: the owner-flagged peers of every seed
242
281
  * this node has applied, keyed by peer id, valued by the seed's multiaddr
@@ -271,6 +310,34 @@ export declare class CadreNode implements SAppIdLookup {
271
310
  * the party if it is ever stranded again.
272
311
  */
273
312
  private bootstrapPeerStore;
313
+ /**
314
+ * Node-local record of the party's enrolled-machine count (see
315
+ * `enrolled-machine-store.ts`), kept so the CONTROL node can declare a
316
+ * block-repair yardstick at bring-up. Constructed (or adopted from
317
+ * `config.enrolledMachines.store`) by {@link initializeEnrolledMachineStore};
318
+ * written by {@link refreshAuthorizedControlPeers}. Like
319
+ * {@link bootstrapPeerStore} it is deliberately NOT cleared by {@link cleanup},
320
+ * so it survives a stop()→start() cycle of the same node instance.
321
+ */
322
+ private enrolledMachineStore;
323
+ /**
324
+ * The enrolled-machine count this node's CONTROL libp2p node was (or will be)
325
+ * built with — read out of {@link enrolledMachineStore} during {@link start},
326
+ * before {@link createControlNode}, and consumed by
327
+ * {@link buildControlNodeOptions}.
328
+ *
329
+ * A captured FIELD rather than a live `store.count()` read, deliberately: the
330
+ * store moves as membership changes, but Optimystic froze the policy when the
331
+ * node was built, so this field is the honest answer to "what did this node
332
+ * actually declare?" and does not drift away from the running node. It is
333
+ * re-read on each {@link start}, which is what makes a stop()→start() cycle pick
334
+ * up a count recorded during the previous run.
335
+ *
336
+ * `undefined` — a brand-new node, an unreadable slot, or the ephemeral default
337
+ * store — means "this node does not know", which `controlClusterPolicy` answers
338
+ * with the frozen base policy itself.
339
+ */
340
+ private declaredEnrolledMachines;
274
341
  /** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
275
342
  private selfRegistrationTimer;
276
343
  /** TTL heartbeat that re-publishes the self record before it goes stale. */
@@ -502,6 +569,32 @@ export declare class CadreNode implements SAppIdLookup {
502
569
  * must never enter this node's dial loop.
503
570
  */
504
571
  private initializeBootstrapPeerStore;
572
+ /**
573
+ * Construct (or adopt) the node-local enrolled-machine store and capture the
574
+ * count this run will declare into {@link declaredEnrolledMachines}.
575
+ *
576
+ * Synchronous for the same reason {@link initializeBootstrapPeerStore} is: an
577
+ * injected store has already loaded its persisted count by the time it is handed
578
+ * in (its `open` is the async part), and the in-memory default has nothing to
579
+ * load. Must run BEFORE {@link createControlNode} — that is the whole point of
580
+ * the record; see `enrolled-machine-store.ts`.
581
+ *
582
+ * The store instance is kept across stop()→start() (so a count recorded during
583
+ * the previous run is not lost with the ephemeral default), but the DECLARED
584
+ * count is re-read every time — which is how a restart applies a number the
585
+ * previous run learned.
586
+ *
587
+ * A store scoped to a different party is a configuration error, fail closed. It
588
+ * is only a repair hint, but a hint sized by a foreign party's membership is a
589
+ * number nobody chose, and the mismatch always means a miswired embedder.
590
+ */
591
+ private initializeEnrolledMachineStore;
592
+ /**
593
+ * The node-local enrolled-machine store (null before {@link start}) — what this
594
+ * node last knew about its party's size. Exposed for diagnostics and for a host
595
+ * that wants to show which repair yardstick the next launch will declare.
596
+ */
597
+ getEnrolledMachineStore(): EnrolledMachineStore | null;
505
598
  /**
506
599
  * The node-local cold-start bootstrap-peer store (null before {@link start}) —
507
600
  * the retained owner addresses {@link reconcileControlCohort}'s cold-start
@@ -534,6 +627,44 @@ export declare class CadreNode implements SAppIdLookup {
534
627
  */
535
628
  trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void>;
536
629
  private createControlNode;
630
+ /**
631
+ * The control libp2p node's Optimystic network name — ONE binding for every
632
+ * derivation from it (`db-p2p` namespaces all of the node's protocol ids as
633
+ * `/optimystic/<networkName>/...`), so the node options and the block-transfer
634
+ * protocol prefix the control backfill dials can never drift apart.
635
+ */
636
+ private controlNetworkName;
637
+ /**
638
+ * Arm the CONTROL network's peer-join block catch-up: push every block in the
639
+ * control database's own raw store to each newly connected AUTHORIZED member
640
+ * this runtime has not yet caught up. This is what physically replicates
641
+ * control blocks committed while the writer was alone — the named
642
+ * collection-header blocks written once at genesis above all, whose revision
643
+ * never moves again, so no later commit ever carries them to a member that
644
+ * joined after them. Without it, such a member that restarts offline reads
645
+ * the affected control tables as EMPTY, silently (`isMember()` answers false
646
+ * for peers it knew about before the restart).
647
+ *
648
+ * Unlike the per-strand instances (see `strand-instance-manager.ts`), pushes
649
+ * are gated on {@link isAuthorizedMember}, judged at push time: the control
650
+ * network's inbound connection gate deliberately admits non-members in
651
+ * several states (seed delivery to an un-enrolled node, an open enrollment
652
+ * window, an outstanding invitation, configured bootstrap/relay peers), and
653
+ * pushing the whole control store to such a peer would hand a stranger the
654
+ * party's entire membership, addresses and strand list. The receiving side
655
+ * needs no work of its own: `createLibp2pNode` registers the block-transfer
656
+ * handler on every node it builds, control node included, and this node's
657
+ * per-stream gate (`authorizeInboundControlStream`) covers the inbound
658
+ * direction. A denied peer is retried on its next `connection:open`, and —
659
+ * because the production join order is connect-then-authorize — on every
660
+ * committed membership change via {@link refreshAuthorizedControlPeers}'s
661
+ * `scheduleConnectedPeers()` call.
662
+ *
663
+ * No-ops (logged) when the embedder configured no control storage or the
664
+ * node exposes no key network — the backfill would have nothing to read or
665
+ * no way to dial.
666
+ */
667
+ private startControlBackfill;
537
668
  /**
538
669
  * The control database's cache-wrapped raw storage, resolved from
539
670
  * `config.storage.provider` on first use and then held in {@link controlStorage}
@@ -857,6 +988,24 @@ export declare class CadreNode implements SAppIdLookup {
857
988
  * producing an unresolvable row.
858
989
  */
859
990
  private getSelfSigningKey;
991
+ /**
992
+ * Does the `Strand` row name THIS machine as its founder — i.e. is its
993
+ * `FounderOwnerKey` this node's own owner key (the key behind its PeerId)?
994
+ * A null/absent column (a consent-seated strand, or a hand-built row) and a
995
+ * node with no owner key both derive `false`: without a positive match this
996
+ * machine must attach, never bootstrap. Pure key derivation, no I/O — cheap
997
+ * enough for {@link launchStrand}'s tracked-instance early return.
998
+ *
999
+ * NOTE: reads "one owner key per machine" — the reference model, where the owner
1000
+ * key IS the key behind the PeerId. Two machines running the SAME identity key
1001
+ * would both derive `true` and each bootstrap on its own replica, which is the
1002
+ * double-`Header` hazard this derivation exists to avoid. Unreachable today: that
1003
+ * configuration also gives both machines one PeerId, which already breaks control
1004
+ * networking well before any strand launches. Revisit if machines ever share an
1005
+ * owner key while holding distinct transport identities — the derivation would
1006
+ * then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
1007
+ */
1008
+ private isSelfFoundedRow;
860
1009
  /**
861
1010
  * The owner keypair (base64url Ed25519) derived from this node's resolved
862
1011
  * identity key. In the single-key reference model the owner signing key is
@@ -939,10 +1088,15 @@ export declare class CadreNode implements SAppIdLookup {
939
1088
  * One address bound to `peerId`, or `null` when it cannot be — logged either
940
1089
  * way, never thrown, so every caller's list-shaping stays total.
941
1090
  *
1091
+ * Used both to normalize addresses this node DIALS (a `CadrePeer` row, a
1092
+ * seed-supplied bootstrap addr) and to normalize the ones it ANNOUNCES
1093
+ * ({@link getStrandMultiaddrs}); the rule is the same either way — an address
1094
+ * that does not reach `peerId` is not an address for `peerId`.
1095
+ *
942
1096
  * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
943
1097
  * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
944
- * above parsed it already), reachable for a seed-supplied one
945
- * ({@link bootstrapDialAddrs}).
1098
+ * above parsed it already) and for our own node's id, reachable for a
1099
+ * seed-supplied one ({@link bootstrapDialAddrs}).
946
1100
  */
947
1101
  private bindAddrToPeer;
948
1102
  /** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
@@ -1375,8 +1529,8 @@ export declare class CadreNode implements SAppIdLookup {
1375
1529
  /**
1376
1530
  * Rebuild a quiesced strand's runtime, re-resolving the volatile cohort input
1377
1531
  * first: the discovery seed may have grown since the strand last ran. Shared by
1378
- * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
1379
- * both apply the same fresh seed resolution. `resumeStrand` is idempotent
1532
+ * the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so both
1533
+ * apply the same fresh resolution. `resumeStrand` is idempotent
1380
1534
  * (returns the live instance unchanged) as a backstop against double-resume.
1381
1535
  */
1382
1536
  private resumeStrandRuntime;
@@ -1437,6 +1591,14 @@ export declare class CadreNode implements SAppIdLookup {
1437
1591
  * Add a strand with its sApp configuration.
1438
1592
  * The hosting application must provide the sApp schema when creating a strand.
1439
1593
  *
1594
+ * This is the ATTACH half only — it starts the local instance and never publishes the
1595
+ * `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
1596
+ * FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
1597
+ * With no explicit `founder` flag, founder-ness is derived from the row's
1598
+ * `FounderOwnerKey` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
1599
+ * published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
1600
+ * anyone else's row joins, without the caller needing to know which it is.
1601
+ *
1440
1602
  * A rejected call leaves nothing running but DOES leave the sApp config
1441
1603
  * registered, deliberately: both an explicit retry and the {@link StrandWatcher}'s
1442
1604
  * automatic relaunch need it. So once the strand's row is visible on the control
@@ -1451,16 +1613,41 @@ export declare class CadreNode implements SAppIdLookup {
1451
1613
  * owner identity, so other cadre members discover it via control-network
1452
1614
  * sync (their {@link StrandWatcher} fires `strand:discovered`).
1453
1615
  *
1454
- * This is the owner-signed `Strand` INSERT that {@link addStrand}
1455
- * deliberately omits: `addStrand` only starts the LOCAL strand instance,
1456
- * whereas publishing makes the strand visible cadre-wide. A typical creator
1457
- * does both (start locally + publish); a discovering peer only does
1458
- * `addStrand` (the row already exists).
1616
+ * **Founding a strand is TWO steps** — this one (publish the row cadre-wide) and
1617
+ * {@link addStrand} (start the local instance, `founder: true` to write the strand's
1618
+ * `Header`/founding membership). `addStrand` deliberately omits the insert: it only
1619
+ * starts the LOCAL instance, whereas publishing makes the strand visible cadre-wide. A
1620
+ * discovering peer only does `addStrand` (the row already exists). Callers founding a
1621
+ * strand should use {@link foundStrand}, which performs both steps and is safe to
1622
+ * re-run — hand-rolling the pair is what left strands half-founded when an app was
1623
+ * killed between them.
1624
+ *
1625
+ * **Idempotent for identical content.** A live row whose `(Type, MemberPrivateKey)` match
1626
+ * the arguments is the state this call would have produced, so the call logs and returns
1627
+ * that row instead of writing — a publish interrupted after it committed can be repeated.
1628
+ * DIFFERENT content on the same id throws, naming the columns that differ, rather than
1629
+ * surfacing the raw `UNIQUE constraint failed: Strand.Id`: silently accepting it would
1630
+ * let a retry reopen a closed strand or swap the key gating its reads. The read and the
1631
+ * insert are not atomic, so a concurrent founder (two machines of one party, same id) is
1632
+ * caught on the insert's uniqueness rejection and resolved the same way — re-read, no-op
1633
+ * on a match, rethrow otherwise. Never an overwrite either way.
1634
+ *
1635
+ * A tombstoned strand is NOT resurrected here: {@link unpublishStrand} deletes the row, so
1636
+ * the idempotent branch is unreachable and the ordinary publish path re-seats it, exactly
1637
+ * as that method documents.
1638
+ *
1639
+ * **Already stuck on `UNIQUE constraint failed: Strand.Id`?** The row is already published
1640
+ * — attach, do NOT republish: `addStrand` (or {@link foundStrand}) with the id and, for a
1641
+ * closed strand, the `MemberPrivateKey` read back from the row. `unpublishStrand` + a
1642
+ * fresh publish also clears it for an OPEN strand, but is destructive for a closed one
1643
+ * (the key exists nowhere else). No app-data wipe is needed for either.
1459
1644
  *
1460
1645
  * The insert is signed with the ed25519 key behind this node's PeerId — which
1461
1646
  * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
1462
- * so peer identity and owner key are one and the same. That key must be
1463
- * enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
1647
+ * so peer identity and owner key are one and the same. That key is also persisted on the
1648
+ * row as `FounderOwnerKey` (the schema pins the column to the verified signer), which is
1649
+ * what later lets any launch derive "this machine is the founder" from the row alone.
1650
+ * The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
1464
1651
  * at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
1465
1652
  * Failing loudly here is intentional: a silently-unpublished strand would run
1466
1653
  * as a local-only island that no peer could ever discover or join.
@@ -1469,10 +1656,62 @@ export declare class CadreNode implements SAppIdLookup {
1469
1656
  * {@link addStrand}).
1470
1657
  * @param type - `'o'` for open (default) or `'c'` for closed.
1471
1658
  * @param memberPrivateKey - Optional membership key for a closed strand.
1472
- * @throws if the node is not started, exposes no owner signing key, the id is blank, or
1473
- * the control DB rejects the (unauthorized) insert.
1474
- */
1475
- publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<void>;
1659
+ * @returns The live `Strand` row — the one just inserted, or the matching one already
1660
+ * there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
1661
+ * on a repeat it is the stored key, not the argument.
1662
+ * @throws if the node is not started, exposes no owner signing key, the id is blank, a
1663
+ * row with the same id holds different content, or the control DB rejects the
1664
+ * (unauthorized) insert.
1665
+ */
1666
+ publishStrand(strandId: string, type?: 'o' | 'c', memberPrivateKey?: string): Promise<StrandRow>;
1667
+ /**
1668
+ * Found a strand — publish its row cadre-wide AND start the local instance as its
1669
+ * founder — in one resumable call. The single entry point for creating a strand; a
1670
+ * caller joining one someone else founded uses {@link addStrand} alone.
1671
+ *
1672
+ * Safe to re-run from any point of interruption, which hand-rolling
1673
+ * {@link publishStrand} + {@link addStrand} is not. Either half can already have
1674
+ * happened:
1675
+ *
1676
+ * - **row already published** → the stored row is adopted rather than re-published, so a
1677
+ * run killed after the insert committed no longer dies on
1678
+ * `UNIQUE constraint failed: Strand.Id`. For a closed strand the row's STORED
1679
+ * `MemberPrivateKey` wins over `config.memberPrivateKey`: a caller that mints a key per
1680
+ * attempt (as the reference apps do) would otherwise present a key that does not match
1681
+ * the membership already seated in the strand.
1682
+ * - **instance already running** → the tracked instance is returned, and — because the
1683
+ * `Strand` row records its publishing machine (`FounderOwnerKey`) — a founder request
1684
+ * against an instance that was first launched as a joiner now runs the (idempotent,
1685
+ * insert-if-absent) founder bootstrap on it rather than silently skipping it
1686
+ * ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
1687
+ * attached first — the reference RN app's `strand:discovered` handler after a restart,
1688
+ * or this node's own {@link StrandWatcher} poll winning `launchStrand`'s
1689
+ * `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
1690
+ *
1691
+ * Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
1692
+ * resolved row's `FounderOwnerKey` is its own owner key. A fresh publish records this
1693
+ * machine, so the common path founds; a machine that lost a concurrent founding race to
1694
+ * a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
1695
+ * machines bootstrapping the same strand on separate replicas is the double-`Header`
1696
+ * hazard. The returned {@link FoundStrandResult.founded} says which happened.
1697
+ *
1698
+ * `Type` is compared, not adopted: a stored row of the other type means the caller and
1699
+ * the control plane disagree about what this strand IS, so it throws.
1700
+ *
1701
+ * @returns The instance AND the row the strand actually runs under — read the membership
1702
+ * key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
1703
+ * @throws if the node is not started, the id is blank, a published row of the same id has
1704
+ * a different `Type`, or either half rejects.
1705
+ */
1706
+ foundStrand(config: FoundStrandConfig): Promise<FoundStrandResult>;
1707
+ /**
1708
+ * Resume onto an already-published `Strand` row: keep its stored content, reject a type
1709
+ * disagreement. The stored `MemberPrivateKey` is authoritative — see {@link foundStrand}
1710
+ * for why a caller-minted key must lose here (and why that is NOT the same rule as
1711
+ * {@link publishStrand}'s, which throws on a key mismatch because it is being asked to
1712
+ * WRITE that key, not to resume onto what already exists).
1713
+ */
1714
+ private adoptPublishedStrand;
1476
1715
  /**
1477
1716
  * Remove this party's `Strand` row from the shared control database — the owner-signed
1478
1717
  * inverse of {@link publishStrand}.
@@ -1624,14 +1863,25 @@ export declare class CadreNode implements SAppIdLookup {
1624
1863
  * seed, starts the strand, and registers it with the hibernation manager
1625
1864
  * before emitting `strand:started`.
1626
1865
  *
1627
- * Idempotent no-op when the strand manager already tracks `strand.Id` — the
1628
- * ordinary founding sequence is `addStrand` (starts it locally) followed by
1629
- * `publishStrand` (makes it visible), so this node's own `StrandWatcher`
1630
- * rediscovers a row it already started and calls this again via
1631
- * `handleStrandAdded`. Without the guard that re-entry would resolve a fresh
1866
+ * Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
1867
+ * one deliberately — their consent-seated rows carry a null `FounderOwnerKey`); when
1868
+ * unset it is DERIVED from the row, so the control-discovered path
1869
+ * (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
1870
+ * without the caller having to know. The derivation is pure key comparison — no I/O.
1871
+ *
1872
+ * Idempotent when the strand manager already tracks `strand.Id` — the watcher
1873
+ * rediscovers a row this node already started and calls this again via
1874
+ * `handleStrandAdded`; without the guard that re-entry would resolve a fresh
1632
1875
  * (already-connected) cohort seed and re-emit `strand:started` for an
1633
1876
  * instance that never stopped. The guard runs before the cohort-seed RPC
1634
- * fan-out, so a rediscovery costs nothing beyond the map lookup.
1877
+ * fan-out, so a rediscovery costs nothing beyond the map lookup plus the (pure)
1878
+ * founder derivation. NOT a silent no-op any more when the launch resolves as a
1879
+ * FOUNDER: the tracked instance may have been launched first as a joiner (an app
1880
+ * attach, or this node's own watcher poll winning the `resolveCohortSeed` window
1881
+ * below), which used to drop the founder request and leave the strand headerless —
1882
+ * now the tracked instance is founded in place
1883
+ * ({@link StrandInstanceManager.foundExistingStrand}), waking it first if quiesced
1884
+ * so the bootstrap actually runs before this resolves.
1635
1885
  */
1636
1886
  private launchStrand;
1637
1887
  /**
@@ -1661,6 +1911,13 @@ export declare class CadreNode implements SAppIdLookup {
1661
1911
  * for a relay we are not yet connected to.
1662
1912
  */
1663
1913
  private resolveCohortSeed;
1914
+ /**
1915
+ * The own-party half of {@link resolveCohortSeed}: strand-network addresses resolved
1916
+ * from CONNECTED cohort siblings over the control-mesh strand-addr RPC, doubling as
1917
+ * the delegate announcement when `delegatePeerId` is given. Empty when the control DB
1918
+ * or node is absent (not yet started / torn down).
1919
+ */
1920
+ private resolveSiblingSeed;
1664
1921
  /**
1665
1922
  * The co-cadre siblings worth sending a strand-addr RPC to right now: cohort
1666
1923
  * members (self excluded) we already hold an open control connection to. They
@@ -1774,9 +2031,18 @@ export declare class CadreNode implements SAppIdLookup {
1774
2031
  */
1775
2032
  private refreshStrandPeerAddrs;
1776
2033
  /**
1777
- * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, then
1778
- * merge their answers into this strand's address book. Errors are logged and
1779
- * swallowed so one strand's failure never costs the others their refresh.
2034
+ * One strand's share of {@link refreshStrandPeerAddrs}: RPC the siblings, union their
2035
+ * answers with this strand's cross-party contact addresses, and merge the lot into the
2036
+ * strand's address book. Errors are logged and swallowed so one strand's failure never
2037
+ * costs the others their refresh.
2038
+ *
2039
+ * `targets` may be EMPTY — a solo node, or one whose only control peers are strangers.
2040
+ * The cross-party addresses still have to be re-merged in that case: they were learned
2041
+ * at formation and can never be re-resolved, so this periodic re-merge is the ONLY
2042
+ * thing standing between them and the peerStore's one-hour expiry. A strand with
2043
+ * neither a sibling to ask nor a contact entry does nothing and leaves its throttle
2044
+ * unstamped, so the next reconcile tick retries rather than sitting out the whole
2045
+ * refresh interval having done nothing.
1780
2046
  */
1781
2047
  private refreshOneStrandPeerAddrs;
1782
2048
  /**
@@ -1797,6 +2063,15 @@ export declare class CadreNode implements SAppIdLookup {
1797
2063
  * handshake, and a bad entry costs one failed dial that ages out at the
1798
2064
  * peerStore's one-hour expiry. No new gating here — cross-party strand trust is
1799
2065
  * `backlog/strand-network-nat-relay-reachability`.
2066
+ *
2067
+ * NOTE: one input does NOT age out — the cross-party addresses a formation
2068
+ * carried back ({@link crossPartyStrandAddrs}). Nothing can re-resolve them, so
2069
+ * {@link refreshStrandPeerAddrs} re-merges the same stored list every pass, which
2070
+ * also means a junk entry from a responder survives for the node's lifetime rather
2071
+ * than an hour. Bounded to {@link MAX_STRAND_ADDRS} per strand, from a party this
2072
+ * node deliberately formed a strand with, and still authority-free — so the
2073
+ * exposure is a handful of failed dials, not a trust hole. Evict on
2074
+ * `unpublishStrand` if that ever stops being an acceptable price.
1800
2075
  */
1801
2076
  private mergeStrandPeerAddrs;
1802
2077
  /**
@@ -2191,6 +2466,23 @@ export declare class CadreNode implements SAppIdLookup {
2191
2466
  * best-effort (a failed read keeps the previous snapshot); never rejects.
2192
2467
  */
2193
2468
  refreshMembershipGate(reason?: string): Promise<void>;
2469
+ /**
2470
+ * Re-materialize a CLOSED strand's revoked-peer deny set now, and hang up any
2471
+ * connected peer it newly covers — the strand-side counterpart to
2472
+ * {@link refreshMembershipGate}.
2473
+ *
2474
+ * Unlike that one, NOTHING calls this automatically: strand membership is
2475
+ * written through the strand's own `Database` handle
2476
+ * (`strand-membership-writer.ts`), which raises no notification this runtime
2477
+ * can hook, and a revocation that arrives by REPLICATION raises none either.
2478
+ * The gate therefore polls (default 30 s). An app that has just called
2479
+ * `revokeMember` or `leaveStrand` should follow the write with this call so
2480
+ * the cut is immediate rather than up to one poll interval late.
2481
+ *
2482
+ * Quiet no-op for a strand that is not running, is quiesced, is open, or has
2483
+ * the gate disabled. Never rejects; resolves once the sweep has finished.
2484
+ */
2485
+ refreshRevocationEnforcement(strandId: string): Promise<void>;
2194
2486
  /**
2195
2487
  * The single in-flight refresh loop behind {@link refreshMembershipGate}: read
2196
2488
  * until the stale flag stays clear, then release the slot.
@@ -2337,6 +2629,17 @@ export declare class CadreNode implements SAppIdLookup {
2337
2629
  * @returns The member key and strand info if successful
2338
2630
  */
2339
2631
  formStrand(invitation: OpenInvitation, disclosure?: StrandFormationDisclosure): Promise<FormStrandResult>;
2632
+ /**
2633
+ * Remember the responder's strand-network addresses for a strand this node just
2634
+ * formed, so the strand's discovery seed has something to work with when the app
2635
+ * launches it (see {@link crossPartyStrandAddrs}).
2636
+ *
2637
+ * Scoped strictly to `strandId`: these addresses reach ONE strand node of ONE other
2638
+ * party and must never seed another strand's mesh or the control peerStore. An empty
2639
+ * list is recorded as no entry at all rather than an empty one, so a later formation
2640
+ * against the same strand that DOES disclose addresses is not shadowed.
2641
+ */
2642
+ private recordCrossPartyStrandAddrs;
2340
2643
  /**
2341
2644
  * Encode an open invitation for out-of-band delivery (QR, link, etc.).
2342
2645
  */