@serfab/cadre-core 1.15.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +16 -7
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +131 -531
  151. package/src/cadre-join-request.ts +188 -167
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +102 -5
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -1,3 +1,5 @@
1
+ import type { NodeProfile } from './types.js';
2
+
1
3
  /**
2
4
  * A single CadrePeer membership row, as returned by
3
5
  * `ControlDatabase.queryCadrePeers()`.
@@ -6,6 +8,8 @@ export interface CohortPeerRow {
6
8
  peerId: string;
7
9
  /** Comma-joined list of multiaddr strings; `''` when no addrs were recorded. */
8
10
  multiaddr: string | null;
11
+ /** The machine's self-declared profile; null or absent until it self-publishes. */
12
+ profile?: NodeProfile | null;
9
13
  }
10
14
 
11
15
  /**
@@ -76,6 +76,7 @@ export class StrandDatabase {
76
76
  private db: Database | null = null;
77
77
  private shutdownStrand: SereusPluginResult['shutdown'] | null = null;
78
78
  private resolvedTransactor: SereusPluginResult['transactor'] | null = null;
79
+ private createEmptyCollections: SereusPluginResult['createEmptyCollections'] | null = null;
79
80
  private readonly config: StrandDatabaseConfig;
80
81
  private initialized = false;
81
82
  private readonly commits: StrandCommitRecord;
@@ -131,6 +132,7 @@ export class StrandDatabase {
131
132
  });
132
133
  this.shutdownStrand = result.shutdown;
133
134
  this.resolvedTransactor = result.transactor;
135
+ this.createEmptyCollections = result.createEmptyCollections;
134
136
  // Before the founder bootstrap, so its commits are reported like any other.
135
137
  this.subscribeToCommits(result);
136
138
  timing('[strandDb:%s] connectToStrand: %dms (hydrated tables=%d, indexes=%d)',
@@ -181,6 +183,7 @@ export class StrandDatabase {
181
183
  sharedMemberPublicKey: closed && memberPrivateKey
182
184
  ? strandMemberKeyPair(memberPrivateKey).publicKeyB64
183
185
  : undefined,
186
+ createEmptyCollections: this.createEmptyCollections!,
184
187
  });
185
188
  }
186
189
 
@@ -40,9 +40,10 @@
40
40
  * Every probe is a read (`Strand.Header`, then `select count(1)` on each `App` table). Reads
41
41
  * never invent a collection (Optimystic's `Collection.open` resolves undefined on an
42
42
  * authoritatively absent header, and throws on an unreachable one), so probing is safe to
43
- * repeat and a throw is just "not yet". A table nobody has written yet reads as absent on
44
- * every machine; the first write to it still creates its collection — see `docs/strands.md`
45
- * ("Joining") for that residual.
43
+ * repeat and a throw is just "not yet". Every table declared at founding has a collection
44
+ * from the start (the founder bootstrap commits them all before the Header), so a probe read
45
+ * fetches it; a table a later schema version adds reads as absent until its first write
46
+ * creates the collection — see `docs/strands.md` ("Joining") for that residual.
46
47
  *
47
48
  * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
48
49
  * only for a non-founder launch whose first probe fails, stopped and dropped in
@@ -142,7 +142,7 @@ export interface StartStrandConfig {
142
142
  * StrandDatabase so the founder bootstrap (Header, founding Member/Manager)
143
143
  * runs once at bring-up. Joiners leave this unset and write nothing. Callers
144
144
  * resolve it BEFORE calling `startStrand` (`CadreNode.launchStrand` derives it
145
- * from the row's `FounderOwnerKey` when no explicit flag is given — see
145
+ * from the row's `FounderPeerId` when no explicit flag is given — see
146
146
  * {@link StrandConfig.founder}); a founder request that arrives while the
147
147
  * instance is already tracked goes through {@link StrandInstanceManager.foundExistingStrand},
148
148
  * never through a repeat `startStrand`.
@@ -305,6 +305,12 @@ export interface FounderBootstrapParams {
305
305
  * holds no shared key has nothing to compare against). Ignored for an open strand.
306
306
  */
307
307
  sharedMemberPublicKey?: string;
308
+ /**
309
+ * Commit an empty collection for every table the strand declares, resolving to how many
310
+ * it committed: the composition's `createEmptyCollections`. Run once, on the founding run
311
+ * that finds no `Header` — see {@link createDeclaredCollections}.
312
+ */
313
+ createEmptyCollections: () => Promise<number>;
308
314
  }
309
315
 
310
316
  /**
@@ -356,12 +362,6 @@ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Mana
356
362
  * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
357
363
  * — this wants a stored "founding closed" marker rather than a scan per call.
358
364
  *
359
- * NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
360
- * written, so this node does not hold its block, and the storage layer consults the
361
- * block's cohort on every read of a block it does not hold — this scan and every
362
- * `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
363
- * membership writes are rare; if they become frequent, file a singleton marker row in
364
- * the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
365
365
  */
366
366
  async function strandHasManagerRevocation(db: Database): Promise<boolean> {
367
367
  for await (const row of db.eval('select TableName from Strand.Revocation')) {
@@ -373,7 +373,50 @@ async function strandHasManagerRevocation(db: Database): Promise<boolean> {
373
373
  }
374
374
 
375
375
  /**
376
- * Insert the singleton `Strand.Header` if absent.
376
+ * Found the strand if its `Strand.Header` is absent: commit every declared table's
377
+ * collection ({@link createDeclaredCollections}), then insert the Header. A Header
378
+ * already present means the strand was founded before, so both are skipped.
379
+ */
380
+ async function foundIfHeaderAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
381
+ if (await strandTableCount(db, 'Header') > 0) {
382
+ log('Header already present for strand %s; skipping', params.strandId);
383
+ return;
384
+ }
385
+ await createDeclaredCollections(params);
386
+ await insertHeader(db, params);
387
+ }
388
+
389
+ /**
390
+ * Commit an empty collection for every table the strand declares, so a read of a table
391
+ * nobody has written is answered from local storage instead of asking the block's cohort
392
+ * to prove the collection absent, and still answers with the other members offline.
393
+ * Peer-join backfill hands these collections to every joiner.
394
+ *
395
+ * Only while the Header is absent, and before it is written: a machine cannot write to a
396
+ * strand before it holds the Header (`strand-first-sync-gate.ts`), so no other machine can
397
+ * yet have created any of these collections and this cannot fork one. On a founder restart
398
+ * the Header is present and this does not run; running it there could commit an empty
399
+ * second history beside a table a joiner wrote while the founder was away. A crash between
400
+ * this and the Header repeats it on the next run, which commits only the collections still
401
+ * missing.
402
+ *
403
+ * NOTE: one `sync()` per collection, in sequence. Measured 2026-10-17 on a lone node over the
404
+ * network transactor (`connectToStrand`, then the step alone, 3 runs each): a nine-table app
405
+ * schema (20 collections with the strand's own) took 32–82 ms in memory and 465–486 ms on
406
+ * `FileRawStorage`; the reference chat schema (13 collections) 19–30 ms and 298–350 ms. About
407
+ * 23 ms per collection on disk, once per strand, before `foundStrand` returns. If founding
408
+ * latency starts to matter (slower phone storage, much larger schemas), commit them as one
409
+ * batch through a coordinator, the way the transaction bridge's `commitBatchLegacy` does.
410
+ */
411
+ async function createDeclaredCollections(params: FounderBootstrapParams): Promise<void> {
412
+ const t0 = performance.now();
413
+ const committed = await params.createEmptyCollections();
414
+ log('Committed %d empty collections for strand %s in %dms',
415
+ committed, params.strandId, Math.round(performance.now() - t0));
416
+ }
417
+
418
+ /**
419
+ * Insert the singleton `Strand.Header`.
377
420
  *
378
421
  * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
379
422
  * NULL; the table declares no `null` columns), so all eight are supplied non-null.
@@ -381,11 +424,7 @@ async function strandHasManagerRevocation(db: Database): Promise<boolean> {
381
424
  * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
382
425
  * so the insert needs no `with context`.
383
426
  */
384
- async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
385
- if (await strandTableCount(db, 'Header') > 0) {
386
- log('Header already present for strand %s; skipping', params.strandId);
387
- return;
388
- }
427
+ async function insertHeader(db: Database, params: FounderBootstrapParams): Promise<void> {
389
428
  await db.exec(
390
429
  `insert into Strand.Header
391
430
  (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
@@ -504,6 +543,10 @@ export async function assertNotPreSplitStrand(db: Database, strandId: string, sh
504
543
  * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
505
544
  * restart / founder re-`addStrand` is a no-op and never double-inserts.
506
545
  *
546
+ * On the run that finds no `Header`, it first commits an empty collection for every
547
+ * table the `Strand` and `App` schemas declare, so no read of a declared table ever
548
+ * needs another member to prove it empty ({@link createDeclaredCollections}).
549
+ *
507
550
  * Behavior by strand type:
508
551
  * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
509
552
  * `OnlyClosed` and would trip that constraint — they are skipped entirely.
@@ -523,8 +566,9 @@ export async function assertNotPreSplitStrand(db: Database, strandId: string, sh
523
566
  * key seated.
524
567
  *
525
568
  * @param db - The strand's Quereus database (schema already applied).
526
- * @param params - Strand id/type, the sApp config for the Header, and (closed only)
527
- * the derived founder keypair and the shared-derived public key to refuse.
569
+ * @param params - Strand id/type, the sApp config for the Header, the composition's
570
+ * empty-collection step, and (closed only) the derived founder keypair and the
571
+ * shared-derived public key to refuse.
528
572
  * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
529
573
  * @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
530
574
  * `Strand.Manager` row is `sharedMemberPublicKey`.
@@ -546,8 +590,9 @@ export async function bootstrapFounderMembership(db: Database, params: FounderBo
546
590
  await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
547
591
  }
548
592
 
549
- // Header is written for every strand; Member/Manager are closed-only.
550
- await insertHeaderIfAbsent(db, params);
593
+ // Every table's collection and the Header are written for every strand; Member/Manager
594
+ // are closed-only.
595
+ await foundIfHeaderAbsent(db, params);
551
596
 
552
597
  if (type === 'o') {
553
598
  // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
@@ -21,9 +21,9 @@
21
21
  * Built over `CadreNode`'s public surface, so `cadre-node.ts` does not grow by the feature.
22
22
  */
23
23
  import debug from 'debug';
24
- import { sign } from '@optimystic/quereus-plugin-crypto';
25
24
  import type { PersistedNetworkState } from '@optimystic/db-p2p';
26
25
  import type { CadreNode } from './cadre-node.js';
26
+ import type { OwnerSign } from './owner-signer.js';
27
27
  import {
28
28
  isPendingJoinConflict,
29
29
  isStrandIdConflict,
@@ -98,10 +98,10 @@ export interface StrandMoveReport {
98
98
  pendingJoinsMoved: number;
99
99
  }
100
100
 
101
- /** {@link adoptStrandBundle} refused before any write: the running node's key is not an owner of its party. */
101
+ /** {@link adoptStrandBundle} refused before any write: the running node's owner key is not an owner of its party. */
102
102
  export class StrandMoveNotOwnerError extends Error {
103
103
  constructor(readonly partyId: string) {
104
- super(`Cannot adopt strands into party ${partyId}: this node's key is not one of its owners, and every re-seated row is owner-signed`);
104
+ super(`Cannot adopt strands into party ${partyId}: this node's owner key is not one of its owners, and every re-seated row is owner-signed`);
105
105
  this.name = 'StrandMoveNotOwnerError';
106
106
  }
107
107
  }
@@ -201,10 +201,11 @@ async function exportFormationInvites(database: ControlDatabase, now: number): P
201
201
  }
202
202
 
203
203
  /**
204
- * Seat `bundle` in the running node's party, every row owner-signed by the node's own identity
205
- * key. Refuses up front, with a typed error and no write, when that key is not an owner of the
206
- * party ({@link StrandMoveNotOwnerError}) or the bundle came from this very party
207
- * ({@link StrandMoveSamePartyError}).
204
+ * Seat `bundle` in the running node's party, every row owner-signed through the node's owner
205
+ * signer (`CadreNode.getOwnerSigner`, wired here by `wireOwnKeyIfOwner` when it is not yet),
206
+ * in one approval for the whole bundle. Refuses up front, with a typed error and no write, when
207
+ * that signer's key is not an owner of the party ({@link StrandMoveNotOwnerError}) or the
208
+ * bundle came from this very party ({@link StrandMoveSamePartyError}).
208
209
  *
209
210
  * Strands first, then the invitations, then the pending joins. Every write is idempotent (a row
210
211
  * already present with the same content is skipped and counted as moved), so the same bundle
@@ -221,11 +222,28 @@ export async function adoptStrandBundle(node: CadreNode, bundle: StrandBundle):
221
222
  if (bundle.partyId === node.partyId) {
222
223
  throw new StrandMoveSamePartyError(node.partyId);
223
224
  }
224
- if (!(await node.wireOwnKeyIfOwner())) {
225
+ const owner = (await node.wireOwnKeyIfOwner()) ? node.getOwnerSigner() : null;
226
+ if (owner === null) {
225
227
  throw new StrandMoveNotOwnerError(node.partyId);
226
228
  }
227
- const owner = node.getIdentityOwnerKey();
228
- const signer: OwnerSigner = { ownerKey: owner.publicKeyB64, signMessage: signMessageWith(owner.privateKeyB64) };
229
+ const action = `Move ${bundle.strands.length} strand(s) from the cadre ${bundle.partyId} into this one`;
230
+ return await owner.approve({ partyId: node.partyId, action }, (sign) =>
231
+ adoptUnderApproval(node, database, bundle, { ownerKey: owner.publicKey, sign }));
232
+ }
233
+
234
+ /** An open owner approval: the key every row is written under, and the approval's `sign`. */
235
+ interface OwnerApproval {
236
+ ownerKey: string;
237
+ sign: OwnerSign;
238
+ }
239
+
240
+ /** {@link adoptStrandBundle}'s writes, every one signed inside the one approval `approval`. */
241
+ async function adoptUnderApproval(
242
+ node: CadreNode,
243
+ database: ControlDatabase,
244
+ bundle: StrandBundle,
245
+ approval: OwnerApproval
246
+ ): Promise<StrandMoveReport> {
229
247
  const report: StrandMoveReport = {
230
248
  fromPartyId: bundle.partyId,
231
249
  moved: [],
@@ -236,15 +254,15 @@ export async function adoptStrandBundle(node: CadreNode, bundle: StrandBundle):
236
254
  pendingJoinsMoved: 0
237
255
  };
238
256
  for (const entry of bundle.strands) {
239
- await adoptStrand(node, database, signer, entry, report);
257
+ await adoptStrand(node, database, approval, entry, report);
240
258
  }
241
259
  const settled = settledStrands(report);
242
260
  const now = Date.now();
243
261
  for (const invite of bundle.formationInvites) {
244
- await adoptFormationInvite(node, database, invite, settled, report, now);
262
+ await adoptFormationInvite(node, database, approval.sign, invite, settled, report, now);
245
263
  }
246
264
  for (const join of bundle.pendingJoins) {
247
- await adoptPendingJoin(database, signer, join, report);
265
+ await adoptPendingJoin(database, approval, join, report);
248
266
  }
249
267
  log('adopt(%s ← %s): %d moved, %d refused, %d failed; %d invitation(s) moved, %d dropped; %d pending join(s)',
250
268
  node.partyId, bundle.partyId, report.moved.length, report.refused.length, report.failed.length,
@@ -252,11 +270,6 @@ export async function adoptStrandBundle(node: CadreNode, bundle: StrandBundle):
252
270
  return report;
253
271
  }
254
272
 
255
- interface OwnerSigner {
256
- ownerKey: string;
257
- signMessage: (message: Uint8Array) => string;
258
- }
259
-
260
273
  /** The strands an adoption has decided on, by outcome, for the invitations bound to them. */
261
274
  interface SettledStrands {
262
275
  moved: Set<string>;
@@ -282,7 +295,7 @@ function settledStrands(report: StrandMoveReport): SettledStrands {
282
295
  async function adoptStrand(
283
296
  node: CadreNode,
284
297
  database: ControlDatabase,
285
- signer: OwnerSigner,
298
+ approval: OwnerApproval,
286
299
  entry: StrandBundleEntry,
287
300
  report: StrandMoveReport
288
301
  ): Promise<void> {
@@ -297,12 +310,12 @@ async function adoptStrand(
297
310
  }
298
311
  await seatStrandPeers(node, entry);
299
312
  if (entry.partyKey !== null) {
300
- await node.ensureStrandPartyKey(strandId, entry.partyKey);
313
+ await node.ensureStrandPartyKey(strandId, entry.partyKey, approval.sign);
301
314
  }
302
315
  if (entry.kind === 'own') {
303
- await node.publishStrand(strandId, entry.row.Type, entry.row.MemberPrivateKey ?? undefined);
316
+ await node.publishStrand(strandId, entry.row.Type, entry.row.MemberPrivateKey ?? undefined, approval.sign);
304
317
  } else {
305
- await seatJoinedStrand(database, signer, entry.row, live.joined);
318
+ await seatJoinedStrand(database, approval, entry.row, live.joined);
306
319
  }
307
320
  report.moved.push(strandId);
308
321
  } catch (error) {
@@ -377,7 +390,7 @@ async function seatStrandPeers(node: CadreNode, entry: StrandBundleEntry): Promi
377
390
  */
378
391
  async function seatJoinedStrand(
379
392
  database: ControlDatabase,
380
- signer: OwnerSigner,
393
+ approval: OwnerApproval,
381
394
  row: StrandBundleEntry['row'],
382
395
  existing: StrandRow | null
383
396
  ): Promise<void> {
@@ -385,7 +398,7 @@ async function seatJoinedStrand(
385
398
  return;
386
399
  }
387
400
  try {
388
- await database.insertJoinedStrand(row, signer.ownerKey, signer.signMessage);
401
+ await database.insertJoinedStrand(row, approval.ownerKey, approval.sign);
389
402
  } catch (error) {
390
403
  if (!isStrandIdConflict(error, 'JoinedStrand')) {
391
404
  throw error;
@@ -412,6 +425,7 @@ async function seatJoinedStrand(
412
425
  async function adoptFormationInvite(
413
426
  node: CadreNode,
414
427
  database: ControlDatabase,
428
+ sign: OwnerSign,
415
429
  invite: FormationInviteEntry,
416
430
  settled: SettledStrands,
417
431
  report: StrandMoveReport,
@@ -434,7 +448,7 @@ async function adoptFormationInvite(
434
448
  expiresAtMs: invite.expiresAtMs ?? undefined,
435
449
  totalUses: invite.usesLeft ?? undefined,
436
450
  strandId: invite.strandId ?? undefined
437
- });
451
+ }, sign);
438
452
  }
439
453
  report.invitationsMoved += 1;
440
454
  } catch (error) {
@@ -464,15 +478,15 @@ async function adoptFormationInvite(
464
478
  */
465
479
  async function adoptPendingJoin(
466
480
  database: ControlDatabase,
467
- signer: OwnerSigner,
481
+ approval: OwnerApproval,
468
482
  join: PendingJoin,
469
483
  report: StrandMoveReport
470
484
  ): Promise<void> {
471
485
  const { StampId: _stampId, outcome, ...request } = join;
472
486
  try {
473
- const live = await seatJoinRequest(database, signer, request);
487
+ const live = await seatJoinRequest(database, approval, request);
474
488
  if (live !== null && outcome !== null && live.outcome === null) {
475
- await database.recordJoinOutcome(live, outcome, signer.ownerKey, signer.signMessage);
489
+ await database.recordJoinOutcome(live, outcome, approval.ownerKey, approval.sign);
476
490
  }
477
491
  report.pendingJoinsMoved += 1;
478
492
  } catch (error) {
@@ -487,9 +501,9 @@ async function adoptPendingJoin(
487
501
  * own ask, left alone) or a dismissed one whose retired stamp is still held until the reap
488
502
  * pass (the party chose not to have it).
489
503
  */
490
- async function seatJoinRequest(database: ControlDatabase, signer: OwnerSigner, request: JoinRequestFields): Promise<PendingJoin | null> {
504
+ async function seatJoinRequest(database: ControlDatabase, approval: OwnerApproval, request: JoinRequestFields): Promise<PendingJoin | null> {
491
505
  try {
492
- return await database.insertJoinRequest(request, signer.ownerKey, signer.signMessage);
506
+ return await database.insertJoinRequest(request, approval.ownerKey, approval.sign);
493
507
  } catch (error) {
494
508
  if (!isPendingJoinConflict(error)) {
495
509
  throw error;
@@ -557,7 +571,7 @@ function isOpenJoin(join: PendingJoin, nowMs: number): boolean {
557
571
  return join.outcome === null && nowMs < join.ExpiresAt;
558
572
  }
559
573
 
560
- /** Content only: `Type` and the read secret. `FounderOwnerKey` is provenance, and a moved strand's changes. */
574
+ /** Content only: `Type` and the read secret. The founder columns are provenance, and a moved strand's change. */
561
575
  function sameStrandContent(live: StrandRow, desired: StrandBundleEntry['row']): boolean {
562
576
  return live.Type === desired.Type && (live.MemberPrivateKey ?? null) === (desired.MemberPrivateKey ?? null);
563
577
  }
@@ -575,11 +589,6 @@ function requireControlDatabase(node: CadreNode, action: string): ControlDatabas
575
589
  return database;
576
590
  }
577
591
 
578
- function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
579
- return (message: Uint8Array): string =>
580
- sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
581
- }
582
-
583
592
  function errorText(error: unknown): string {
584
593
  return error instanceof Error ? error.message : String(error);
585
594
  }
@@ -234,9 +234,8 @@ class StrandPeerRedialLoop implements StrandPeerRedial {
234
234
  *
235
235
  * NOTE: a round whose dials failed within milliseconds (a relay connection that is still
236
236
  * closing, the member's relay reservation not back yet) waits as long as one whose dials ran
237
- * out their budget. In `joiner-first-commit-over-slow-relay`'s third arm that first wait is what
238
- * leaves the joiner writable with no member connected. If reconnecting within seconds of a cut
239
- * ever matters, retry a round that failed that fast after a short delay instead.
237
+ * out their budget. If reconnecting within seconds of a cut ever matters, retry a round that
238
+ * failed that fast after a short delay instead.
240
239
  *
241
240
  * NOTE: one such fast failure is `ConnectionClosedError: The connection muxer is "closed"`. A
242
241
  * relay that aborts its connection to this node cannot tell it so (`@libp2p/websockets`'