@serfab/cadre-core 1.17.0 → 1.19.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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1182 -566
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +17 -14
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -219,8 +219,8 @@ async function execStrandTransaction(db, statements, options) {
219
219
  */
220
220
  function revocationStatement(tableName, stampId, retiree) {
221
221
  return {
222
- sql: `insert into Strand.Revocation (TableName, StampId)
223
- with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
222
+ sql: `insert into Strand.Revocation (TableName, StampId)
223
+ with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
224
224
  values (:revocationTable, :revocationStampId);`,
225
225
  params: {
226
226
  revocationSignerKey: retiree.publicKeyB64,
@@ -286,47 +286,37 @@ async function strandHasManagerRevocation(db) {
286
286
  return false;
287
287
  }
288
288
  /**
289
- * Found the strand if its `Strand.Header` is absent: commit every declared table's
290
- * collection ({@link createDeclaredCollections}), then insert the Header. A Header
291
- * already present means the strand was founded before, so both are skipped.
292
- */
293
- async function foundIfHeaderAbsent(db, params) {
294
- if (await strandTableCount(db, 'Header') > 0) {
295
- log('Header already present for strand %s; skipping', params.strandId);
296
- return;
297
- }
298
- await createDeclaredCollections(params);
299
- await insertHeader(db, params);
300
- }
301
- /**
302
- * Commit an empty collection for every table the strand declares, so a read of a table
303
- * nobody has written is answered from local storage instead of asking the block's cohort
304
- * to prove the collection absent, and still answers with the other members offline.
305
- * Peer-join backfill hands these collections to every joiner.
306
- *
307
- * Only while the Header is absent, and before it is written: a machine cannot write to a
308
- * strand before it holds the Header (`strand-first-sync-gate.ts`), so no other machine can
309
- * yet have created any of these collections and this cannot fork one. On a founder restart
310
- * the Header is present and this does not run; running it there could commit an empty
311
- * second history beside a table a joiner wrote while the founder was away. A crash between
312
- * this and the Header repeats it on the next run, which commits only the collections still
289
+ * Commit an empty collection for every table the strand declares except `writtenTables`, so a
290
+ * read of a table nobody has written is answered from local storage instead of asking the
291
+ * block's cohort to prove the collection absent, and still answers with the other members
292
+ * offline. Peer-join backfill hands these collections to every joiner. The founding
293
+ * transaction's own tables are left out because its inserts create their collections anyway.
294
+ *
295
+ * Only while the Header is absent, and before the founding transaction writes it: a machine
296
+ * cannot write to a strand before it holds the Header (`strand-first-sync-gate.ts`), so no other
297
+ * machine can yet have created any of these collections and this cannot fork one. On a founder
298
+ * restart the Header is present and this does not run; running it there could commit an empty
299
+ * second history beside a table a joiner wrote while the founder was away. A crash between this
300
+ * and the founding commit repeats it on the next run, which commits only the collections still
313
301
  * missing.
314
302
  *
315
303
  * NOTE: one `sync()` per collection, in sequence. Measured 2026-10-17 on a lone node over the
316
304
  * network transactor (`connectToStrand`, then the step alone, 3 runs each): a nine-table app
317
305
  * schema (20 collections with the strand's own) took 32–82 ms in memory and 465–486 ms on
318
306
  * `FileRawStorage`; the reference chat schema (13 collections) 19–30 ms and 298–350 ms. About
319
- * 23 ms per collection on disk, once per strand, before `foundStrand` returns. If founding
320
- * latency starts to matter (slower phone storage, much larger schemas), commit them as one
321
- * batch through a coordinator, the way the transaction bridge's `commitBatchLegacy` does.
307
+ * 23 ms per collection on disk, once per strand, before `foundStrand` returns. Leaving out the
308
+ * founding transaction's tables brings the chat schema to 6 collections on a closed strand whose
309
+ * founder binds its peer, and 12 on an open one. If founding latency starts to matter (slower
310
+ * phone storage, much larger schemas), commit them as one batch through a coordinator, the way
311
+ * the transaction bridge's `commitBatchLegacy` does.
322
312
  */
323
- async function createDeclaredCollections(params) {
313
+ async function createDeclaredCollections(params, writtenTables) {
324
314
  const t0 = performance.now();
325
- const committed = await params.createEmptyCollections();
315
+ const committed = await params.createEmptyCollections(writtenTables);
326
316
  log('Committed %d empty collections for strand %s in %dms', committed, params.strandId, Math.round(performance.now() - t0));
327
317
  }
328
318
  /**
329
- * Insert the singleton `Strand.Header`.
319
+ * The singleton `Strand.Header` insert.
330
320
  *
331
321
  * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
332
322
  * NULL; the table declares no `null` columns), so all eight are supplied non-null.
@@ -334,84 +324,127 @@ async function createDeclaredCollections(params) {
334
324
  * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
335
325
  * so the insert needs no `with context`.
336
326
  */
337
- async function insertHeader(db, params) {
338
- await db.exec(`insert into Strand.Header
339
- (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
340
- values (?, ?, ?, ?, ?, ?, ?, ?)`, [
341
- params.strandId,
342
- params.type,
343
- params.sApp.id,
344
- params.sApp.version,
345
- params.sApp.schema,
346
- params.sApp.signature ?? '',
347
- STRAND_ENGINE,
348
- STRAND_ENGINE_VERSION,
349
- ]);
350
- log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
327
+ function headerRow(params) {
328
+ return {
329
+ table: 'Strand.Header',
330
+ statements: {
331
+ sql: `insert into Strand.Header
332
+ (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
333
+ values (:headerId, :headerType, :headerSAppId, :headerSAppVersion, :headerSAppSchema,
334
+ :headerSAppSignature, :headerEngine, :headerEngineVersion);`,
335
+ params: {
336
+ headerId: params.strandId,
337
+ headerType: params.type,
338
+ headerSAppId: params.sApp.id,
339
+ headerSAppVersion: params.sApp.version,
340
+ headerSAppSchema: params.sApp.schema,
341
+ headerSAppSignature: params.sApp.signature ?? '',
342
+ headerEngine: STRAND_ENGINE,
343
+ headerEngineVersion: STRAND_ENGINE_VERSION,
344
+ },
345
+ },
346
+ };
351
347
  }
352
348
  /**
353
- * Insert the founding `Strand.Member` if no member exists yet.
349
+ * The founding `Strand.Member` insert.
354
350
  *
355
351
  * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
356
352
  * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
357
353
  * needed — the context fields are explicit nulls. The branch also caps the
358
354
  * POST-image at one member, so it waives authorization for exactly this single
359
355
  * seat, never for a batch. The row still mints a fresh StampId — the column is
360
- * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
361
- * count makes this idempotent: a re-run (the founder re-`addStrand`/
362
- * `resumeStrand`) finds the member present and skips.
356
+ * NOT NULL on every path; bootstrap only waives the SIGNATURE.
363
357
  */
364
- async function insertFounderMemberIfAbsent(db, memberKey, strandId) {
365
- if (await strandTableCount(db, 'Member') > 0) {
366
- log('Member already present for strand %s; skipping founder Member', strandId);
367
- return;
368
- }
369
- await db.exec(`insert into Strand.Member (Key, StampId)
370
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
371
- values (?, ?)`, [memberKey, generateStrandStampId()]);
372
- log('Inserted founding Member for strand %s', strandId);
358
+ function founderMemberRow(memberKey) {
359
+ return {
360
+ table: 'Strand.Member',
361
+ statements: {
362
+ sql: `insert into Strand.Member (Key, StampId)
363
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
364
+ values (:founderMemberKey, :founderMemberStampId);`,
365
+ params: { founderMemberKey: memberKey, founderMemberStampId: generateStrandStampId() },
366
+ },
367
+ };
368
+ }
369
+ /**
370
+ * The founding `Strand.Manager` insert, at `Generation = 0` — the root of the manager
371
+ * lineage; the bootstrap branch of `Manager.Authorized` requires exactly that value, and
372
+ * every later manager is seated at a strictly greater generation (see {@link addManager}).
373
+ * No signature is needed (a fresh StampId is still minted — the column is NOT NULL).
374
+ *
375
+ * The bootstrap branch, `Manager.MemberExists` and `Manager.OnlyClosed` all have subqueries,
376
+ * so they defer to commit and read the live `Member` and `Header` tables: a founding
377
+ * `Manager` whose `Member` and `Header` are neither committed already nor in its own
378
+ * transaction is rejected.
379
+ */
380
+ function founderManagerRow(memberKey) {
381
+ return {
382
+ table: 'Strand.Manager',
383
+ statements: {
384
+ sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
385
+ with context ManagerKey = null, Signature = null
386
+ values (:founderManagerKey, 0, :founderManagerStampId);`,
387
+ params: { founderManagerKey: memberKey, founderManagerStampId: generateStrandStampId() },
388
+ },
389
+ };
373
390
  }
374
391
  /**
375
- * Insert the founding `Strand.Manager` if no manager exists yet.
376
- *
377
- * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
378
- * no signature is needed (a fresh StampId is still minted — the column is NOT
379
- * NULL). The founder is seated at `Generation = 0` — the root of
380
- * the manager lineage; the bootstrap branch requires exactly that value, and every
381
- * later manager is seated at a strictly greater generation (see {@link addManager}).
382
- * Idempotent via the same count guard.
383
- *
384
- * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
385
- * the founding state and now reads `exists (select 1 from Member M where M.Key =
386
- * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
387
- * founding `Member` row MUST already exist when this commits — a Manager-first
388
- * seeding path is rejected. It must also run AFTER the Header insert:
389
- * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
390
- * sequential auto-commit Header→Member→Manager order in
391
- * {@link bootstrapFounderMembership} satisfies both.
392
- *
393
- * SEALED strands are skipped, not re-founded: a strand whose sole manager has
394
- * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
395
- * closed for good (the schema's bootstrap branch requires no `Manager`-table
396
- * `Revocation` row to exist). Attempting the founding insert here would be
397
- * schema-rejected and turn every founder restart of a sealed strand into a
398
- * loud failure — so this mirrors the schema's own gate and returns quietly.
399
- * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
400
- * rows at all, so that restart still completes the founding.
392
+ * Whether the founding `Strand.Manager` still has to be seated: no manager exists and the
393
+ * strand is not SEALED. A strand whose sole manager has {@link sealStrand}ed also has zero
394
+ * `Manager` rows, but its founding branch is closed for good (the schema's bootstrap branch
395
+ * requires no `Manager`-table `Revocation` row to exist). Seating it there would be
396
+ * schema-rejected and turn every founder restart of a sealed strand into a loud failure — so
397
+ * this mirrors the schema's own gate. A founding that never committed its Manager leaves no
398
+ * Revocation rows at all, so that restart still completes the founding.
401
399
  */
402
- async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
400
+ async function founderManagerMissing(db, strandId) {
403
401
  if (await strandTableCount(db, 'Manager') > 0) {
404
402
  log('Manager already present for strand %s; skipping founder Manager', strandId);
405
- return;
403
+ return false;
406
404
  }
407
405
  if (await strandHasManagerRevocation(db)) {
408
406
  log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
409
- return;
407
+ return false;
410
408
  }
411
- await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
412
- with context ManagerKey = null, Signature = null
413
- values (?, 0, ?)`, [memberKey, generateStrandStampId()]);
414
- log('Inserted founding Manager for strand %s', strandId);
409
+ return true;
410
+ }
411
+ /**
412
+ * This machine's `Strand.MemberPeer` binding, unless there is no peer id to bind or the
413
+ * binding is already present (a re-run after a torn founding commit).
414
+ */
415
+ async function founderBindingIfAbsent(db, founderKeyPair, peerId) {
416
+ if (peerId === undefined) {
417
+ return [];
418
+ }
419
+ if (await memberPeerStampId(db, founderKeyPair.publicKeyB64, peerId) !== undefined) {
420
+ log('MemberPeer (%s, %s) already present; skipping founder binding', founderKeyPair.publicKeyB64, peerId);
421
+ return [];
422
+ }
423
+ return [{ table: 'Strand.MemberPeer', statements: memberPeerInsertStatement(founderKeyPair, peerId) }];
424
+ }
425
+ /**
426
+ * The closed strand's founding rows that are still missing, in Member → Manager → MemberPeer
427
+ * order. Each is behind an insert-if-absent guard, so a re-run after a crash or a torn commit
428
+ * writes only what is missing.
429
+ *
430
+ * The binding joins only a run that seats the founding `Member`. With a member row already
431
+ * present the founder may since have left the strand or been removed, and
432
+ * `MemberPeer.MemberExists` would then refuse the whole founding transaction; the membership
433
+ * reconciler, which binds only once it sees this party's member row, writes it instead.
434
+ */
435
+ async function missingClosedFoundingRows(db, founderKeyPair, params) {
436
+ const memberKey = founderKeyPair.publicKeyB64;
437
+ const seatsMember = await strandTableCount(db, 'Member') === 0;
438
+ if (!seatsMember) {
439
+ log('Member already present for strand %s; skipping founder Member', params.strandId);
440
+ }
441
+ const seatsManager = await founderManagerMissing(db, params.strandId);
442
+ const binding = seatsMember ? await founderBindingIfAbsent(db, founderKeyPair, params.founderPeerId) : [];
443
+ return [
444
+ ...(seatsMember ? [founderMemberRow(memberKey)] : []),
445
+ ...(seatsManager ? [founderManagerRow(memberKey)] : []),
446
+ ...binding,
447
+ ];
415
448
  }
416
449
  /**
417
450
  * Refuse a closed strand whose membership still holds the key derived from the shared
@@ -437,21 +470,23 @@ export async function assertNotPreSplitStrand(db, strandId, sharedMemberPublicKe
437
470
  *
438
471
  * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
439
472
  * published the strand). A joiner never calls this — it receives these rows via
440
- * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
441
- * restart / founder re-`addStrand` is a no-op and never double-inserts.
473
+ * Optimystic sync. It reads which founding rows are missing, then writes all of them in
474
+ * ONE strand transaction. Every row is insert-if-absent, so a restart / founder
475
+ * re-`addStrand` writes nothing, and a re-run after a failed or torn founding commit
476
+ * writes only what is missing.
442
477
  *
443
478
  * On the run that finds no `Header`, it first commits an empty collection for every
444
- * table the `Strand` and `App` schemas declare, so no read of a declared table ever
445
- * needs another member to prove it empty ({@link createDeclaredCollections}).
479
+ * table the `Strand` and `App` schemas declare except the ones the founding transaction
480
+ * writes, so no read of a declared table ever needs another member to prove it empty
481
+ * ({@link createDeclaredCollections}).
446
482
  *
447
483
  * Behavior by strand type:
448
- * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
449
- * `OnlyClosed` and would trip that constraint — they are skipped entirely.
450
- * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
451
- * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
452
- * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
453
- * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
454
- * evaluate at commit.
484
+ * - **Open (`'o'`)**: `Header` only. `Member`/`Manager`/`Invite` are `OnlyClosed` and
485
+ * would trip that constraint — they are skipped entirely.
486
+ * - **Closed (`'c'`)**: `Header(Type='c')`, the founding `Member` and `Manager` (both
487
+ * `founderKeyPair.publicKeyB64`) and, when `founderPeerId` is supplied, this machine's
488
+ * `MemberPeer` binding. Every check that reads another founding row has a subquery, so
489
+ * it defers to commit and sees the whole transaction.
455
490
  *
456
491
  * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
457
492
  * Manager could never admit anyone, so failing loudly here (which propagates out
@@ -460,12 +495,14 @@ export async function assertNotPreSplitStrand(db, strandId, sharedMemberPublicKe
460
495
  * A closed strand whose membership holds `sharedMemberPublicKey` throws
461
496
  * {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
462
497
  * guards would otherwise skip over its pre-split founding rows and leave the shared
463
- * key seated.
498
+ * key seated. Checked only when the `Header` is already present: a strand founded before
499
+ * the key split committed its Header first, alone, so a strand with no Header has none
500
+ * of those rows.
464
501
  *
465
502
  * @param db - The strand's Quereus database (schema already applied).
466
503
  * @param params - Strand id/type, the sApp config for the Header, the composition's
467
- * empty-collection step, and (closed only) the derived founder keypair and the
468
- * shared-derived public key to refuse.
504
+ * empty-collection step, and (closed only) the derived founder keypair, the
505
+ * shared-derived public key to refuse and this machine's peer id to bind.
469
506
  * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
470
507
  * @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
471
508
  * `Strand.Manager` row is `sharedMemberPublicKey`.
@@ -480,20 +517,35 @@ export async function bootstrapFounderMembership(db, params) {
480
517
  throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
481
518
  'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.');
482
519
  }
483
- if (type === 'c' && sharedMemberPublicKey !== undefined) {
520
+ const headerPresent = await strandTableCount(db, 'Header') > 0;
521
+ if (headerPresent && type === 'c' && sharedMemberPublicKey !== undefined) {
484
522
  await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
485
523
  }
486
- // Every table's collection and the Header are written for every strand; Member/Manager
487
- // are closed-only.
488
- await foundIfHeaderAbsent(db, params);
489
- if (type === 'o') {
490
- // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
524
+ const rows = [
525
+ ...(headerPresent ? [] : [headerRow(params)]),
526
+ ...(type === 'c' ? await missingClosedFoundingRows(db, founderKeyPair, params) : []),
527
+ ];
528
+ if (!headerPresent) {
529
+ await createDeclaredCollections(params, rows.map((row) => row.table));
530
+ }
531
+ if (rows.length === 0) {
532
+ log('Strand %s is already founded; nothing to write', strandId);
491
533
  return;
492
534
  }
493
- const memberKey = founderKeyPair.publicKeyB64;
494
- await insertFounderMemberIfAbsent(db, memberKey, strandId);
495
- await insertFounderManagerIfAbsent(db, memberKey, strandId);
496
- log('Founder bootstrap complete for closed strand %s', strandId);
535
+ // NOTE: a torn founding commit (some of the batch's collections saved, others not) can leave
536
+ // the Header committed beside a founding table with no collection, which committing every
537
+ // table's empty collection first used to rule out. A sibling machine of this party could then
538
+ // create that collection itself (its reconciler binding its own peer into MemberPeer). The
539
+ // re-run's guards, and this machine's reconciler, read each table before writing it, and a
540
+ // networked read asks the cohort, so the healing write appends to the sibling's collection
541
+ // unless the sibling is unreachable at that moment. A torn founding that saves this
542
+ // machine's MemberPeer without its Member also leaves an orphaned binding, which every
543
+ // machine's revocation enforcer (this one's and any joiner's) reads as a removed founder
544
+ // until a later run of this bootstrap seats the Member. If torn founding commits are ever
545
+ // observed, commit the founding tables a sibling writes (MemberPeer) as empty collections
546
+ // before this batch again.
547
+ await execStrandTransaction(db, combineStatements(...rows.map((row) => row.statements)));
548
+ log('Founder bootstrap for strand %s wrote %s', strandId, rows.map((row) => row.table).join(', '));
497
549
  }
498
550
  /**
499
551
  * Issue a single-use invitation to join a closed strand.
@@ -525,8 +577,8 @@ export async function issueInvite(db, params) {
525
577
  const payload = `${inviteKey}|${expirationSegment}`;
526
578
  const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
527
579
  const inviteSignature = signStrandPayload(payload, invitePrivateKey);
528
- await db.exec(`insert into Strand.Invite (Key, Expiration)
529
- with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
580
+ await db.exec(`insert into Strand.Invite (Key, Expiration)
581
+ with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
530
582
  values (?, ?)`, [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn]);
531
583
  log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
532
584
  return { inviteKey, invitePrivateKey };
@@ -583,8 +635,8 @@ export async function consumeInvite(db, params, options) {
583
635
  // Member first — admitted by the deferred invite branch (the matching ConsumedInvite
584
636
  // after it), so no manager signature is supplied.
585
637
  const member = {
586
- sql: `insert into Strand.Member (Key, StampId)
587
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
638
+ sql: `insert into Strand.Member (Key, StampId)
639
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
588
640
  values (:memberKey, :memberStampId);`,
589
641
  params: { memberKey, memberStampId: generateStrandStampId() },
590
642
  };
@@ -605,8 +657,8 @@ export async function consumeInvite(db, params, options) {
605
657
  async function consumedInviteStatement(db, params) {
606
658
  const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
607
659
  return {
608
- sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
609
- with context InviteSignature = :consumedSignature, Now = :consumedNow
660
+ sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
661
+ with context InviteSignature = :consumedSignature, Now = :consumedNow
610
662
  values (:consumedInviteKey, :consumedMemberKey);`,
611
663
  params: {
612
664
  consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
@@ -712,8 +764,8 @@ export async function cancelInvite(db, params) {
712
764
  return;
713
765
  }
714
766
  const signature = signStrandApproval(['Strand.CancelledInvite', 'cancel', inviteKey], managerKeyPair.privateKeyB64);
715
- await db.exec(`insert into Strand.CancelledInvite (InviteKey)
716
- with context ManagerKey = ?, ManagerSignature = ?
767
+ await db.exec(`insert into Strand.CancelledInvite (InviteKey)
768
+ with context ManagerKey = ?, ManagerSignature = ?
717
769
  values (?)`, [managerKeyPair.publicKeyB64, signature, inviteKey]);
718
770
  log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
719
771
  }
@@ -826,8 +878,8 @@ function memberAddByManagerStatement(params) {
826
878
  const { managerKeyPair, memberKey } = params;
827
879
  const stampId = generateStrandStampId();
828
880
  return {
829
- sql: `insert into Strand.Member (Key, StampId)
830
- with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
881
+ sql: `insert into Strand.Member (Key, StampId)
882
+ with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
831
883
  values (:memberKey, :memberStampId);`,
832
884
  params: {
833
885
  memberAdmitterKey: managerKeyPair.publicKeyB64,
@@ -873,12 +925,11 @@ function memberAddByManagerStatement(params) {
873
925
  * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
874
926
  * schema NOTE states).
875
927
  *
876
- * Callers running inside a cadre runtime should follow this write with
877
- * `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
878
- * machines are refused at the network layer by the strand's revoked-peer gate
879
- * (`strand-revocation-enforcer.ts`), which polls for membership changes because
880
- * this bare `Database` handle raises no event the runtime can hook — so without
881
- * that call the cut lands up to one poll interval (default 30 s) later.
928
+ * Inside a cadre runtime the removed party's machines are then refused at the
929
+ * network layer by the strand's revoked-peer gate (`strand-revocation-enforcer.ts`),
930
+ * which re-reads membership when this write commits on the runtime's own
931
+ * `Database`. A caller that wants to await the cut follows the write with
932
+ * `CadreNode.refreshRevocationEnforcement(strandId)`, which resolves after it.
882
933
  *
883
934
  * @param db - The closed strand's database.
884
935
  * @param params - The revoking manager keypair and the target member key.
@@ -896,8 +947,8 @@ export async function revokeMember(db, params, options) {
896
947
  return;
897
948
  }
898
949
  const removal = {
899
- sql: `delete from Strand.Member
900
- with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
950
+ sql: `delete from Strand.Member
951
+ with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
901
952
  where Key = :memberKey;`,
902
953
  params: {
903
954
  memberRemoverKey: managerKeyPair.publicKeyB64,
@@ -922,10 +973,10 @@ export async function revokeMember(db, params, options) {
922
973
  * The same floors as {@link revokeMember} apply: a manager must resign first
923
974
  * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
924
975
  *
925
- * Like {@link revokeMember}, a caller inside a cadre runtime should follow this
926
- * with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
927
- * makes the departing node notice its own departure promptly (the gate emits
928
- * `strand:revoked` when it finds this node's own peer id revoked).
976
+ * Like {@link revokeMember}, inside a cadre runtime the write wakes the strand's
977
+ * revoked-peer gate, which emits `strand:revoked` when it finds this node's own
978
+ * peer id revoked; `CadreNode.refreshRevocationEnforcement(strandId)` awaits that
979
+ * refresh.
929
980
  *
930
981
  * @param db - The closed strand's database.
931
982
  * @param params - The departing member's own keypair.
@@ -942,8 +993,8 @@ export async function leaveStrand(db, params, options) {
942
993
  return;
943
994
  }
944
995
  const departure = {
945
- sql: `delete from Strand.Member
946
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
996
+ sql: `delete from Strand.Member
997
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
947
998
  where Key = :memberKey;`,
948
999
  params: {
949
1000
  memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
@@ -996,10 +1047,19 @@ export async function registerMemberPeer(db, params, options) {
996
1047
  log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
997
1048
  return;
998
1049
  }
1050
+ await execStrandTransaction(db, memberPeerInsertStatement(memberKeyPair, peerId), options);
1051
+ log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1052
+ }
1053
+ /**
1054
+ * The self-signed `Strand.MemberPeer` insert binding `peerId` to the member, under a fresh
1055
+ * stamp bound into the add-tagged digest (see {@link registerMemberPeer}).
1056
+ */
1057
+ function memberPeerInsertStatement(memberKeyPair, peerId) {
1058
+ const memberKey = memberKeyPair.publicKeyB64;
999
1059
  const stampId = generateStrandStampId();
1000
- await execStrandTransaction(db, {
1001
- sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
1002
- with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1060
+ return {
1061
+ sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
1062
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1003
1063
  values (:peerMemberKey, :peerId, :peerStampId);`,
1004
1064
  params: {
1005
1065
  peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
@@ -1007,8 +1067,7 @@ export async function registerMemberPeer(db, params, options) {
1007
1067
  peerId,
1008
1068
  peerStampId: stampId,
1009
1069
  },
1010
- }, options);
1011
- log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1070
+ };
1012
1071
  }
1013
1072
  /**
1014
1073
  * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
@@ -1150,8 +1209,8 @@ function ownMemberPeerRemovalStatements(params, stampId) {
1150
1209
  const { memberKeyPair, peerId } = params;
1151
1210
  const memberKey = memberKeyPair.publicKeyB64;
1152
1211
  const removal = {
1153
- sql: `delete from Strand.MemberPeer
1154
- with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1212
+ sql: `delete from Strand.MemberPeer
1213
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1155
1214
  where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1156
1215
  params: {
1157
1216
  peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
@@ -1175,8 +1234,8 @@ function ownMemberPeerRemovalStatements(params, stampId) {
1175
1234
  function memberPeerRemovalByManagerStatements(params, stampId) {
1176
1235
  const { managerKeyPair, memberKey, peerId } = params;
1177
1236
  const removal = {
1178
- sql: `delete from Strand.MemberPeer
1179
- with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1237
+ sql: `delete from Strand.MemberPeer
1238
+ with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1180
1239
  where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1181
1240
  params: {
1182
1241
  peerManagerKey: managerKeyPair.publicKeyB64,
@@ -1262,8 +1321,8 @@ async function managerAddStatement(db, params) {
1262
1321
  const generation = authorizer == null ? 1 : authorizer.generation + 1;
1263
1322
  const stampId = generateStrandStampId();
1264
1323
  return {
1265
- sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1266
- with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1324
+ sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1325
+ with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1267
1326
  values (:managerMemberKey, :managerGeneration, :managerStampId);`,
1268
1327
  params: {
1269
1328
  managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
@@ -1407,8 +1466,8 @@ export async function removeManager(db, params, options) {
1407
1466
  */
1408
1467
  function managerRemovalStatements(signer, signature, targetManagerKey, targetStampId) {
1409
1468
  const removal = {
1410
- sql: `delete from Strand.Manager
1411
- with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1469
+ sql: `delete from Strand.Manager
1470
+ with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1412
1471
  where MemberKey = :managerMemberKey;`,
1413
1472
  params: {
1414
1473
  managerRemoverKey: signer.publicKeyB64,