@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  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 +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  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 +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  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 +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -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,
@@ -276,12 +276,6 @@ async function strandTableCount(db, table) {
276
276
  * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
277
277
  * — this wants a stored "founding closed" marker rather than a scan per call.
278
278
  *
279
- * NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
280
- * written, so this node does not hold its block, and the storage layer consults the
281
- * block's cohort on every read of a block it does not hold — this scan and every
282
- * `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
283
- * membership writes are rare; if they become frequent, file a singleton marker row in
284
- * the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
285
279
  */
286
280
  async function strandHasManagerRevocation(db) {
287
281
  for await (const row of db.eval('select TableName from Strand.Revocation')) {
@@ -292,7 +286,37 @@ async function strandHasManagerRevocation(db) {
292
286
  return false;
293
287
  }
294
288
  /**
295
- * Insert the singleton `Strand.Header` if absent.
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
301
+ * missing.
302
+ *
303
+ * NOTE: one `sync()` per collection, in sequence. Measured 2026-10-17 on a lone node over the
304
+ * network transactor (`connectToStrand`, then the step alone, 3 runs each): a nine-table app
305
+ * schema (20 collections with the strand's own) took 32–82 ms in memory and 465–486 ms on
306
+ * `FileRawStorage`; the reference chat schema (13 collections) 19–30 ms and 298–350 ms. About
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.
312
+ */
313
+ async function createDeclaredCollections(params, writtenTables) {
314
+ const t0 = performance.now();
315
+ const committed = await params.createEmptyCollections(writtenTables);
316
+ log('Committed %d empty collections for strand %s in %dms', committed, params.strandId, Math.round(performance.now() - t0));
317
+ }
318
+ /**
319
+ * The singleton `Strand.Header` insert.
296
320
  *
297
321
  * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
298
322
  * NULL; the table declares no `null` columns), so all eight are supplied non-null.
@@ -300,88 +324,127 @@ async function strandHasManagerRevocation(db) {
300
324
  * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
301
325
  * so the insert needs no `with context`.
302
326
  */
303
- async function insertHeaderIfAbsent(db, params) {
304
- if (await strandTableCount(db, 'Header') > 0) {
305
- log('Header already present for strand %s; skipping', params.strandId);
306
- return;
307
- }
308
- await db.exec(`insert into Strand.Header
309
- (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
310
- values (?, ?, ?, ?, ?, ?, ?, ?)`, [
311
- params.strandId,
312
- params.type,
313
- params.sApp.id,
314
- params.sApp.version,
315
- params.sApp.schema,
316
- params.sApp.signature ?? '',
317
- STRAND_ENGINE,
318
- STRAND_ENGINE_VERSION,
319
- ]);
320
- 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
+ };
321
347
  }
322
348
  /**
323
- * Insert the founding `Strand.Member` if no member exists yet.
349
+ * The founding `Strand.Member` insert.
324
350
  *
325
351
  * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
326
352
  * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
327
353
  * needed — the context fields are explicit nulls. The branch also caps the
328
354
  * POST-image at one member, so it waives authorization for exactly this single
329
355
  * seat, never for a batch. The row still mints a fresh StampId — the column is
330
- * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
331
- * count makes this idempotent: a re-run (the founder re-`addStrand`/
332
- * `resumeStrand`) finds the member present and skips.
356
+ * NOT NULL on every path; bootstrap only waives the SIGNATURE.
333
357
  */
334
- async function insertFounderMemberIfAbsent(db, memberKey, strandId) {
335
- if (await strandTableCount(db, 'Member') > 0) {
336
- log('Member already present for strand %s; skipping founder Member', strandId);
337
- return;
338
- }
339
- await db.exec(`insert into Strand.Member (Key, StampId)
340
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
341
- values (?, ?)`, [memberKey, generateStrandStampId()]);
342
- 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
+ };
343
390
  }
344
391
  /**
345
- * Insert the founding `Strand.Manager` if no manager exists yet.
346
- *
347
- * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
348
- * no signature is needed (a fresh StampId is still minted — the column is NOT
349
- * NULL). The founder is seated at `Generation = 0` — the root of
350
- * the manager lineage; the bootstrap branch requires exactly that value, and every
351
- * later manager is seated at a strictly greater generation (see {@link addManager}).
352
- * Idempotent via the same count guard.
353
- *
354
- * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
355
- * the founding state and now reads `exists (select 1 from Member M where M.Key =
356
- * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
357
- * founding `Member` row MUST already exist when this commits — a Manager-first
358
- * seeding path is rejected. It must also run AFTER the Header insert:
359
- * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
360
- * sequential auto-commit Header→Member→Manager order in
361
- * {@link bootstrapFounderMembership} satisfies both.
362
- *
363
- * SEALED strands are skipped, not re-founded: a strand whose sole manager has
364
- * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
365
- * closed for good (the schema's bootstrap branch requires no `Manager`-table
366
- * `Revocation` row to exist). Attempting the founding insert here would be
367
- * schema-rejected and turn every founder restart of a sealed strand into a
368
- * loud failure — so this mirrors the schema's own gate and returns quietly.
369
- * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
370
- * 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.
371
399
  */
372
- async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
400
+ async function founderManagerMissing(db, strandId) {
373
401
  if (await strandTableCount(db, 'Manager') > 0) {
374
402
  log('Manager already present for strand %s; skipping founder Manager', strandId);
375
- return;
403
+ return false;
376
404
  }
377
405
  if (await strandHasManagerRevocation(db)) {
378
406
  log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
379
- return;
407
+ return false;
380
408
  }
381
- await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
382
- with context ManagerKey = null, Signature = null
383
- values (?, 0, ?)`, [memberKey, generateStrandStampId()]);
384
- 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
+ ];
385
448
  }
386
449
  /**
387
450
  * Refuse a closed strand whose membership still holds the key derived from the shared
@@ -407,17 +470,23 @@ export async function assertNotPreSplitStrand(db, strandId, sharedMemberPublicKe
407
470
  *
408
471
  * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
409
472
  * published the strand). A joiner never calls this — it receives these rows via
410
- * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
411
- * 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.
477
+ *
478
+ * On the run that finds no `Header`, it first commits an empty collection for every
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}).
412
482
  *
413
483
  * Behavior by strand type:
414
- * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
415
- * `OnlyClosed` and would trip that constraint — they are skipped entirely.
416
- * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
417
- * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
418
- * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
419
- * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
420
- * 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.
421
490
  *
422
491
  * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
423
492
  * Manager could never admit anyone, so failing loudly here (which propagates out
@@ -426,11 +495,14 @@ export async function assertNotPreSplitStrand(db, strandId, sharedMemberPublicKe
426
495
  * A closed strand whose membership holds `sharedMemberPublicKey` throws
427
496
  * {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
428
497
  * guards would otherwise skip over its pre-split founding rows and leave the shared
429
- * 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.
430
501
  *
431
502
  * @param db - The strand's Quereus database (schema already applied).
432
- * @param params - Strand id/type, the sApp config for the Header, and (closed only)
433
- * the derived founder keypair and the shared-derived public key to refuse.
503
+ * @param params - Strand id/type, the sApp config for the Header, the composition's
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.
434
506
  * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
435
507
  * @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
436
508
  * `Strand.Manager` row is `sharedMemberPublicKey`.
@@ -445,19 +517,35 @@ export async function bootstrapFounderMembership(db, params) {
445
517
  throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
446
518
  'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.');
447
519
  }
448
- if (type === 'c' && sharedMemberPublicKey !== undefined) {
520
+ const headerPresent = await strandTableCount(db, 'Header') > 0;
521
+ if (headerPresent && type === 'c' && sharedMemberPublicKey !== undefined) {
449
522
  await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
450
523
  }
451
- // Header is written for every strand; Member/Manager are closed-only.
452
- await insertHeaderIfAbsent(db, params);
453
- if (type === 'o') {
454
- // 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);
455
533
  return;
456
534
  }
457
- const memberKey = founderKeyPair.publicKeyB64;
458
- await insertFounderMemberIfAbsent(db, memberKey, strandId);
459
- await insertFounderManagerIfAbsent(db, memberKey, strandId);
460
- 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(', '));
461
549
  }
462
550
  /**
463
551
  * Issue a single-use invitation to join a closed strand.
@@ -489,8 +577,8 @@ export async function issueInvite(db, params) {
489
577
  const payload = `${inviteKey}|${expirationSegment}`;
490
578
  const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
491
579
  const inviteSignature = signStrandPayload(payload, invitePrivateKey);
492
- await db.exec(`insert into Strand.Invite (Key, Expiration)
493
- with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
580
+ await db.exec(`insert into Strand.Invite (Key, Expiration)
581
+ with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
494
582
  values (?, ?)`, [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn]);
495
583
  log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
496
584
  return { inviteKey, invitePrivateKey };
@@ -547,8 +635,8 @@ export async function consumeInvite(db, params, options) {
547
635
  // Member first — admitted by the deferred invite branch (the matching ConsumedInvite
548
636
  // after it), so no manager signature is supplied.
549
637
  const member = {
550
- sql: `insert into Strand.Member (Key, StampId)
551
- 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
552
640
  values (:memberKey, :memberStampId);`,
553
641
  params: { memberKey, memberStampId: generateStrandStampId() },
554
642
  };
@@ -569,8 +657,8 @@ export async function consumeInvite(db, params, options) {
569
657
  async function consumedInviteStatement(db, params) {
570
658
  const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
571
659
  return {
572
- sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
573
- with context InviteSignature = :consumedSignature, Now = :consumedNow
660
+ sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
661
+ with context InviteSignature = :consumedSignature, Now = :consumedNow
574
662
  values (:consumedInviteKey, :consumedMemberKey);`,
575
663
  params: {
576
664
  consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
@@ -676,8 +764,8 @@ export async function cancelInvite(db, params) {
676
764
  return;
677
765
  }
678
766
  const signature = signStrandApproval(['Strand.CancelledInvite', 'cancel', inviteKey], managerKeyPair.privateKeyB64);
679
- await db.exec(`insert into Strand.CancelledInvite (InviteKey)
680
- with context ManagerKey = ?, ManagerSignature = ?
767
+ await db.exec(`insert into Strand.CancelledInvite (InviteKey)
768
+ with context ManagerKey = ?, ManagerSignature = ?
681
769
  values (?)`, [managerKeyPair.publicKeyB64, signature, inviteKey]);
682
770
  log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
683
771
  }
@@ -790,8 +878,8 @@ function memberAddByManagerStatement(params) {
790
878
  const { managerKeyPair, memberKey } = params;
791
879
  const stampId = generateStrandStampId();
792
880
  return {
793
- sql: `insert into Strand.Member (Key, StampId)
794
- 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
795
883
  values (:memberKey, :memberStampId);`,
796
884
  params: {
797
885
  memberAdmitterKey: managerKeyPair.publicKeyB64,
@@ -837,12 +925,11 @@ function memberAddByManagerStatement(params) {
837
925
  * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
838
926
  * schema NOTE states).
839
927
  *
840
- * Callers running inside a cadre runtime should follow this write with
841
- * `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
842
- * machines are refused at the network layer by the strand's revoked-peer gate
843
- * (`strand-revocation-enforcer.ts`), which polls for membership changes because
844
- * this bare `Database` handle raises no event the runtime can hook — so without
845
- * 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.
846
933
  *
847
934
  * @param db - The closed strand's database.
848
935
  * @param params - The revoking manager keypair and the target member key.
@@ -860,8 +947,8 @@ export async function revokeMember(db, params, options) {
860
947
  return;
861
948
  }
862
949
  const removal = {
863
- sql: `delete from Strand.Member
864
- with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
950
+ sql: `delete from Strand.Member
951
+ with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
865
952
  where Key = :memberKey;`,
866
953
  params: {
867
954
  memberRemoverKey: managerKeyPair.publicKeyB64,
@@ -886,10 +973,10 @@ export async function revokeMember(db, params, options) {
886
973
  * The same floors as {@link revokeMember} apply: a manager must resign first
887
974
  * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
888
975
  *
889
- * Like {@link revokeMember}, a caller inside a cadre runtime should follow this
890
- * with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
891
- * makes the departing node notice its own departure promptly (the gate emits
892
- * `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.
893
980
  *
894
981
  * @param db - The closed strand's database.
895
982
  * @param params - The departing member's own keypair.
@@ -906,8 +993,8 @@ export async function leaveStrand(db, params, options) {
906
993
  return;
907
994
  }
908
995
  const departure = {
909
- sql: `delete from Strand.Member
910
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
996
+ sql: `delete from Strand.Member
997
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
911
998
  where Key = :memberKey;`,
912
999
  params: {
913
1000
  memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
@@ -960,10 +1047,19 @@ export async function registerMemberPeer(db, params, options) {
960
1047
  log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
961
1048
  return;
962
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;
963
1059
  const stampId = generateStrandStampId();
964
- await execStrandTransaction(db, {
965
- sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
966
- 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
967
1063
  values (:peerMemberKey, :peerId, :peerStampId);`,
968
1064
  params: {
969
1065
  peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
@@ -971,8 +1067,7 @@ export async function registerMemberPeer(db, params, options) {
971
1067
  peerId,
972
1068
  peerStampId: stampId,
973
1069
  },
974
- }, options);
975
- log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1070
+ };
976
1071
  }
977
1072
  /**
978
1073
  * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
@@ -1114,8 +1209,8 @@ function ownMemberPeerRemovalStatements(params, stampId) {
1114
1209
  const { memberKeyPair, peerId } = params;
1115
1210
  const memberKey = memberKeyPair.publicKeyB64;
1116
1211
  const removal = {
1117
- sql: `delete from Strand.MemberPeer
1118
- with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1212
+ sql: `delete from Strand.MemberPeer
1213
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1119
1214
  where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1120
1215
  params: {
1121
1216
  peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
@@ -1139,8 +1234,8 @@ function ownMemberPeerRemovalStatements(params, stampId) {
1139
1234
  function memberPeerRemovalByManagerStatements(params, stampId) {
1140
1235
  const { managerKeyPair, memberKey, peerId } = params;
1141
1236
  const removal = {
1142
- sql: `delete from Strand.MemberPeer
1143
- with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1237
+ sql: `delete from Strand.MemberPeer
1238
+ with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1144
1239
  where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1145
1240
  params: {
1146
1241
  peerManagerKey: managerKeyPair.publicKeyB64,
@@ -1226,8 +1321,8 @@ async function managerAddStatement(db, params) {
1226
1321
  const generation = authorizer == null ? 1 : authorizer.generation + 1;
1227
1322
  const stampId = generateStrandStampId();
1228
1323
  return {
1229
- sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1230
- with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1324
+ sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1325
+ with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1231
1326
  values (:managerMemberKey, :managerGeneration, :managerStampId);`,
1232
1327
  params: {
1233
1328
  managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
@@ -1371,8 +1466,8 @@ export async function removeManager(db, params, options) {
1371
1466
  */
1372
1467
  function managerRemovalStatements(signer, signature, targetManagerKey, targetStampId) {
1373
1468
  const removal = {
1374
- sql: `delete from Strand.Manager
1375
- with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1469
+ sql: `delete from Strand.Manager
1470
+ with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1376
1471
  where MemberKey = :managerMemberKey;`,
1377
1472
  params: {
1378
1473
  managerRemoverKey: signer.publicKeyB64,