@serfab/cadre-core 0.12.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -115,49 +115,118 @@ export function generateStrandStampId() {
115
115
  const bytes = randomBytes(256, 'bytes');
116
116
  return uint8ArrayToString(bytes, 'base64url');
117
117
  }
118
+ // ── Write batches (every membership write is one indivisible transaction) ──────
118
119
  /**
119
- * Run `fn` inside one explicit strand transaction: begin, fn, commit — with the
120
- * standard rollback-on-failure shape.
121
- *
122
- * Used wherever two `Strand.*` writes must land atomically: the invite-consume
123
- * pair (Member + ConsumedInvite) and every delete-plus-tombstone pair (the
124
- * `RevocationRecorded` constraints require the `Revocation` row in the SAME
125
- * transaction as the delete it retires).
126
- *
127
- * JOINS a caller-owned transaction instead of opening its own: Quereus's
128
- * `Database.beginTransaction()` hard-throws "Transaction already active" on any
129
- * nesting, and a caller composing several writers in one transaction (e.g. a
130
- * resign + revoke pair that must land atomically) is exactly the case where the
131
- * deferred constraints should fire once, at the CALLER's commit. In joined mode
132
- * commit/rollback belong to the caller, so failures simply propagate.
120
+ * A membership writer that runs only in a transaction of its own found another
121
+ * transaction already open on the strand database, and wrote nothing. Raised only
122
+ * under `joinOpenTransaction: false` (see {@link StrandWriteOptions}) — the background
123
+ * callers, which treat it as "the app is mid-transaction; try again later".
133
124
  */
134
- async function inStrandTransaction(db, fn) {
135
- if (!db.getAutocommit()) {
136
- await fn();
137
- return;
125
+ export class StrandTransactionBusyError extends Error {
126
+ constructor(options) {
127
+ super('Another transaction is open on the strand database. This membership write runs only in a '
128
+ + 'transaction of its own, so nothing was written; retry once that transaction closes.', options);
129
+ this.name = 'StrandTransactionBusyError';
138
130
  }
139
- await db.beginTransaction();
131
+ }
132
+ /**
133
+ * Join statement fragments into one batch. Named parameters bind across the whole batch,
134
+ * so each fragment builder uses its own parameter names; a name bound twice would
135
+ * silently hand one fragment the other's value, so it throws instead.
136
+ */
137
+ function combineStatements(...fragments) {
138
+ const params = {};
139
+ for (const fragment of fragments) {
140
+ for (const [name, value] of Object.entries(fragment.params)) {
141
+ if (name in params) {
142
+ throw new Error(`Strand write batch binds the parameter :${name} in two statements`);
143
+ }
144
+ params[name] = value;
145
+ }
146
+ }
147
+ return { sql: fragments.map((fragment) => fragment.sql).join('\n'), params };
148
+ }
149
+ /** Quereus's refusal of a `begin` issued while a transaction is already open. */
150
+ const NESTED_BEGIN_REFUSAL = /^Cannot begin transaction: already in a transaction$/;
151
+ function isNestedBeginRefusal(error) {
152
+ return error instanceof Error && NESTED_BEGIN_REFUSAL.test(error.message);
153
+ }
154
+ /**
155
+ * Run `statements` as one indivisible transaction on the shared strand database.
156
+ *
157
+ * The whole transaction is ONE `db.exec` batch — `begin transaction; <statements> commit;`
158
+ * — because Quereus holds its exec mutex for a whole batch but releases it between
159
+ * separate calls, and any caller's statement that runs while an explicit transaction is
160
+ * open lands inside it. A `beginTransaction()` / `exec` / `commit()` sequence therefore
161
+ * swept the app's concurrent writes into a background writer's transaction, and lost them
162
+ * when that transaction failed.
163
+ *
164
+ * Whether a transaction is already open is decided by the batch's own `begin`, under the
165
+ * mutex, not by `getAutocommit()` beforehand: that reads false whenever another caller's
166
+ * autocommit statement is merely in flight, which would make a background writer refuse
167
+ * spuriously and a composing caller run its statements as separate autocommits.
168
+ *
169
+ * - The `begin` succeeds: the batch commits or fails as a unit. A failure at `commit`
170
+ * (deferred constraints, a refused optimystic commit) leaves nothing open. A failure at
171
+ * an earlier statement leaves the batch's transaction open, so it is rolled back here.
172
+ * - The `begin` is refused because a transaction is already open: nothing has run. With
173
+ * `joinOpenTransaction: false` that is a {@link StrandTransactionBusyError} (with the
174
+ * refusal as `cause`), and the open transaction — someone else's — is left alone.
175
+ * Otherwise the statements run inside it, and its owner commits or rolls back.
176
+ *
177
+ * NOTE: interim until Quereus ships a batch that rolls back before releasing its mutex —
178
+ * requested as `../quereus/tickets/plan/exec-batch-as-one-transaction.md`; switch to that
179
+ * API when a Quereus release carries it, which also retires the message match in
180
+ * {@link isNestedBeginRefusal}. Until then two residual windows remain. (1) A statement
181
+ * that fails before `commit` (a primary-key collision such as a sibling machine seating the
182
+ * same `Member` first, an immediate CHECK, a storage read error) releases the mutex with the
183
+ * batch's transaction still open: an app `exec` already queued behind the batch runs inside
184
+ * it, and the rollback below discards that app write although its `exec` resolved. The
185
+ * common failures (an `Invite` not replicated yet, expired, cancelled, sealed, and
186
+ * optimystic commit refusals) all fail at `commit` and leave nothing open. (2) The rollback
187
+ * decision reads `getAutocommit()` after the batch has released the mutex, so after a
188
+ * `commit`-time failure a queued app statement in flight reads as "still open" too; the
189
+ * rollback then runs after that statement's own commit and finds nothing to undo — unless
190
+ * an app `beginTransaction()` also slipped in between, which it would end.
191
+ */
192
+ async function execStrandTransaction(db, statements, options) {
140
193
  try {
141
- await fn();
142
- await db.commit();
194
+ await db.exec(`begin transaction;\n${statements.sql}\ncommit;`, statements.params);
195
+ return;
143
196
  }
144
197
  catch (error) {
145
- // A failed commit() already tears down the transaction, so rollback() would
146
- // throw "No transaction active" and mask the real cause — swallow only that.
147
- try {
148
- await db.rollback();
198
+ if (!isNestedBeginRefusal(error)) {
199
+ await rollbackBatchLeftOpen(db, error);
200
+ throw error;
149
201
  }
150
- catch (rollbackError) {
151
- log('Rollback after strand transaction failure was a no-op: %s', rollbackError);
202
+ if (options?.joinOpenTransaction === false) {
203
+ throw new StrandTransactionBusyError({ cause: error });
152
204
  }
153
- throw error;
154
205
  }
206
+ await db.exec(statements.sql, statements.params);
155
207
  }
156
208
  /**
157
- * File the `Strand.Revocation` tombstone that retires a deleted row's StampId.
209
+ * Roll back the batch's own transaction when a statement before `commit` failed and left
210
+ * it open; a `commit`-time failure has already closed it. See the NOTE on
211
+ * {@link execStrandTransaction} for what this cannot tell apart.
212
+ */
213
+ async function rollbackBatchLeftOpen(db, cause) {
214
+ if (db.getAutocommit()) {
215
+ return;
216
+ }
217
+ try {
218
+ await db.rollback();
219
+ log('Rolled back a strand write batch left open by a failed statement: %s', cause);
220
+ }
221
+ catch (rollbackError) {
222
+ log('Rolling back a failed strand write batch failed (%s): %o', cause, rollbackError);
223
+ }
224
+ }
225
+ /**
226
+ * The `Strand.Revocation` tombstone insert that retires a deleted row's StampId.
158
227
  *
159
- * Runs inside the SAME transaction as the delete it accompanies — the guarded
160
- * tables' `RevocationRecorded` (deferred) requires the tombstone at commit, and
228
+ * Always batched with the delete it accompanies — the guarded tables'
229
+ * `RevocationRecorded` (deferred) requires the tombstone at commit, and
161
230
  * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
162
231
  * the pair only ever lands together.
163
232
  *
@@ -168,16 +237,42 @@ async function inStrandTransaction(db, fn) {
168
237
  * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
169
238
  * context is non-null — both fields are always supplied.
170
239
  *
171
- * @param db - The strand database, INSIDE an open transaction.
172
240
  * @param tableName - Which guarded table the stamp belonged to.
173
241
  * @param stampId - The retired stamp.
174
242
  * @param retiree - The committed member filing the tombstone (the delete's own signer).
175
243
  */
176
- async function insertRevocation(db, tableName, stampId, retiree) {
177
- const signature = signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64);
178
- await db.exec(`insert into Strand.Revocation (TableName, StampId)
179
- with context MemberKey = ?, Signature = ?
180
- values (?, ?)`, [retiree.publicKeyB64, signature, tableName, stampId]);
244
+ function revocationStatement(tableName, stampId, retiree) {
245
+ return {
246
+ sql: `insert into Strand.Revocation (TableName, StampId)
247
+ with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
248
+ values (:revocationTable, :revocationStampId);`,
249
+ params: {
250
+ revocationSignerKey: retiree.publicKeyB64,
251
+ revocationSignature: signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64),
252
+ revocationTable: tableName,
253
+ revocationStampId: stampId,
254
+ },
255
+ };
256
+ }
257
+ /**
258
+ * A closed strand founded BEFORE the per-party identity split: its founding
259
+ * `Strand.Member` (and, unless handed on, `Strand.Manager`) is the key derived from the
260
+ * shared `MemberPrivateKey`, which every joining party holds, so any member can sign as
261
+ * the founder. There is no repair — rewriting the membership cannot be trusted, since any
262
+ * joiner could already have admitted or revoked anyone, or could race the rewrite — so
263
+ * the strand must be recreated. Thrown by {@link assertNotPreSplitStrand} (a founder
264
+ * bootstrap, or membership-invite issuance); the formation layer maps it to a
265
+ * non-retryable rejection.
266
+ */
267
+ export class PreSplitStrandIdentityError extends Error {
268
+ constructor(strandId) {
269
+ super(`Closed strand ${strandId} was founded before per-party strand identity: its founding ` +
270
+ 'member key is derived from the shared MemberPrivateKey that every joining party holds, ' +
271
+ 'so any member can act as its founder. It cannot be repaired — recreate the strand ' +
272
+ '(unpublish it and found a new one).');
273
+ this.strandId = strandId;
274
+ this.name = 'PreSplitStrandIdentityError';
275
+ }
181
276
  }
182
277
  /**
183
278
  * Count rows in a `Strand.*` table as seen by this database instance.
@@ -204,6 +299,13 @@ async function strandTableCount(db, table) {
204
299
  * scale; if membership churn ever makes that table large — the growth the
205
300
  * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
206
301
  * — this wants a stored "founding closed" marker rather than a scan per call.
302
+ *
303
+ * NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
304
+ * written, so this node does not hold its block, and the storage layer consults the
305
+ * block's cohort on every read of a block it does not hold — this scan and every
306
+ * `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
307
+ * membership writes are rare; if they become frequent, file a singleton marker row in
308
+ * the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
207
309
  */
208
310
  async function strandHasManagerRevocation(db) {
209
311
  for await (const row of db.eval('select TableName from Strand.Revocation')) {
@@ -305,6 +407,25 @@ async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
305
407
  values (?, 0, ?)`, [memberKey, generateStrandStampId()]);
306
408
  log('Inserted founding Manager for strand %s', strandId);
307
409
  }
410
+ /**
411
+ * Refuse a closed strand whose membership still holds the key derived from the shared
412
+ * `MemberPrivateKey` (see {@link FounderBootstrapParams.sharedMemberPublicKey}). `Member`
413
+ * is the primary probe — every manager is a member (`Manager.MemberExists`), so it also
414
+ * catches a pre-split founder who handed management to another key and resigned, or a
415
+ * sealed pre-split strand. `Manager` is probed too, for the partition case the schema
416
+ * notes on `MemberExists` (a Manager row converged without its Member row). Cannot
417
+ * misfire: nothing seats the shared-derived key since the split, and rows not yet loaded
418
+ * read as absent.
419
+ *
420
+ * @throws {PreSplitStrandIdentityError} when a `Strand.Member` or `Strand.Manager` row is
421
+ * the shared-derived key.
422
+ */
423
+ export async function assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey) {
424
+ if (await memberStampId(db, sharedMemberPublicKey) !== undefined
425
+ || await managerRow(db, sharedMemberPublicKey) !== undefined) {
426
+ throw new PreSplitStrandIdentityError(strandId);
427
+ }
428
+ }
308
429
  /**
309
430
  * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
310
431
  *
@@ -326,20 +447,30 @@ async function insertFounderManagerIfAbsent(db, memberKey, strandId) {
326
447
  * Manager could never admit anyone, so failing loudly here (which propagates out
327
448
  * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
328
449
  *
450
+ * A closed strand whose membership holds `sharedMemberPublicKey` throws
451
+ * {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
452
+ * guards would otherwise skip over its pre-split founding rows and leave the shared
453
+ * key seated.
454
+ *
329
455
  * @param db - The strand's Quereus database (schema already applied).
330
456
  * @param params - Strand id/type, the sApp config for the Header, and (closed only)
331
- * the derived founder keypair.
457
+ * the derived founder keypair and the shared-derived public key to refuse.
332
458
  * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
459
+ * @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
460
+ * `Strand.Manager` row is `sharedMemberPublicKey`.
333
461
  */
334
462
  export async function bootstrapFounderMembership(db, params) {
335
- const { strandId, type, founderKeyPair } = params;
463
+ const { strandId, type, founderKeyPair, sharedMemberPublicKey } = params;
336
464
  log('Founder bootstrap for strand %s (type %s)', strandId, type);
337
465
  // Validate BEFORE writing anything: a closed strand with no founder key must
338
466
  // fail without leaving a closed `Header` that has no founding Member/Manager
339
467
  // (such a strand could never admit anyone).
340
468
  if (type === 'c' && !founderKeyPair) {
341
- throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
342
- 'A closed strand needs a founding Member + Manager or it can never admit members.');
469
+ throw new Error(`Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
470
+ 'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.');
471
+ }
472
+ if (type === 'c' && sharedMemberPublicKey !== undefined) {
473
+ await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
343
474
  }
344
475
  // Header is written for every strand; Member/Manager are closed-only.
345
476
  await insertHeaderIfAbsent(db, params);
@@ -428,32 +559,81 @@ export async function issueInvite(db, params) {
428
559
  * @param db - The closed strand's database.
429
560
  * @param params - The invite key/secret, the joining member's public key, and an
430
561
  * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
562
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
431
563
  * @throws If any constraint rejects (bad signature, missing invite, or an expired
432
564
  * invite); the whole transaction rolls back (neither the `Member` nor the
433
565
  * `ConsumedInvite` row survives).
566
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
567
+ * transaction is open; nothing was written.
434
568
  */
435
- export async function consumeInvite(db, params) {
436
- const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
437
- const usagePayload = `${inviteKey}|${memberKey}`;
438
- const inviteSignature = signStrandPayload(usagePayload, invitePrivateKey);
439
- // Canonicalise "now" the same way issueInvite canonicalises Expiration, so the
440
- // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
441
- // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
442
- const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
443
- await inStrandTransaction(db, async () => {
444
- // 1. Member — admitted by the deferred invite branch (the matching
445
- // ConsumedInvite below), so no manager signature is supplied.
446
- await db.exec(`insert into Strand.Member (Key, StampId)
447
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
448
- values (?, ?)`, [memberKey, generateStrandStampId()]);
449
- // 2. ConsumedInvite — proves possession of the invite private key and that the
450
- // invite has not expired (NotExpired gate against the canonical Now).
451
- await db.exec(`insert into Strand.ConsumedInvite (InviteKey, MemberKey)
452
- with context InviteSignature = ?, Now = ?
453
- values (?, ?)`, [inviteSignature, nowCanonical, inviteKey, memberKey]);
454
- });
569
+ export async function consumeInvite(db, params, options) {
570
+ const { inviteKey, memberKey } = params;
571
+ // Member first — admitted by the deferred invite branch (the matching ConsumedInvite
572
+ // after it), so no manager signature is supplied.
573
+ const member = {
574
+ sql: `insert into Strand.Member (Key, StampId)
575
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
576
+ values (:memberKey, :memberStampId);`,
577
+ params: { memberKey, memberStampId: generateStrandStampId() },
578
+ };
579
+ await execStrandTransaction(db, combineStatements(member, await consumedInviteStatement(db, params)), options);
455
580
  log('Consumed invite %s -> member %s', inviteKey, memberKey);
456
581
  }
582
+ /**
583
+ * The `Strand.ConsumedInvite` marker row insert — the possession-plus-freshness proof
584
+ * shared by {@link consumeInvite} (batched after its `Member` insert) and
585
+ * {@link burnInvite} (alone, against an already-seated member).
586
+ *
587
+ * The invite signature covers `InviteKey || '|' || MemberKey` (the `ValidUsage` gate),
588
+ * and "now" is canonicalised the same way {@link issueInvite} canonicalises
589
+ * `Expiration`, so the schema's `I.Expiration > context.Now` compares like-for-like
590
+ * canonical strings. Plain runtime Date.now() — the tess Workflow restriction is on
591
+ * scripts, not libs.
592
+ */
593
+ async function consumedInviteStatement(db, params) {
594
+ const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
595
+ return {
596
+ sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
597
+ with context InviteSignature = :consumedSignature, Now = :consumedNow
598
+ values (:consumedInviteKey, :consumedMemberKey);`,
599
+ params: {
600
+ consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
601
+ consumedNow: await canonicalDatetime(db, nowMs ?? Date.now()),
602
+ consumedInviteKey: inviteKey,
603
+ consumedMemberKey: memberKey,
604
+ },
605
+ };
606
+ }
607
+ /**
608
+ * Burn an invitation against an ALREADY-SEATED member: insert the
609
+ * `Strand.ConsumedInvite` row alone, naming the existing `Member.Key`, so the bearer
610
+ * credential can never be spent by anyone else.
611
+ *
612
+ * The already-member arm of the bring-up membership reconciler
613
+ * (`strand-membership-reconciler.ts`): a machine that finds its party's `Member` row
614
+ * already present (a sibling machine redeemed the invitation's twin first, or a
615
+ * manager admitted the party directly) may still hold an UNSPENT formation-delivered
616
+ * invitation — a bearer credential whoever presents can join with. Every
617
+ * `ConsumedInvite` constraint is satisfiable without a same-transaction `Member`
618
+ * insert (`MemberExists` reads the live table), so this is one statement whose
619
+ * deferred checks fire at its own commit. It seats nobody now or later:
620
+ * `Member.Authorized`'s invite branch requires a same-transaction FRESH consumption,
621
+ * so the row it leaves is exactly as inert as any other spent invitation's.
622
+ *
623
+ * @param db - The closed strand's database (the member already exists).
624
+ * @param params - The invite key/secret and the EXISTING member's public key, plus the
625
+ * optional `nowMs` instant for the expiry gate (default `Date.now()`).
626
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
627
+ * @throws If any constraint rejects — already consumed (the `InviteKey` primary key),
628
+ * cancelled, expired, or a sealed strand. Callers treat a burn failure as "already
629
+ * dead" and log rather than retry.
630
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
631
+ * transaction is open; nothing was tried, so the invitation is exactly as live as before.
632
+ */
633
+ export async function burnInvite(db, params, options) {
634
+ await execStrandTransaction(db, await consumedInviteStatement(db, params), options);
635
+ log('Burned invite %s against existing member %s', params.inviteKey, params.memberKey);
636
+ }
457
637
  /**
458
638
  * Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
459
639
  *
@@ -593,6 +773,17 @@ async function memberStampId(db, memberKey) {
593
773
  }
594
774
  return undefined;
595
775
  }
776
+ /**
777
+ * Whether `memberKey` holds a live `Strand.Member` row, as seen by THIS database
778
+ * instance — the membership probe behind the bring-up reconciler
779
+ * (`strand-membership-reconciler.ts`). Reads via {@link memberStampId}'s unfiltered
780
+ * scan + JavaScript compare (the {@link scanMemberPeers} argument for why a PK point
781
+ * lookup is not reliable on a networked strand). Local visibility only: `false` can
782
+ * mean "not replicated here yet", never a durable verdict about the strand.
783
+ */
784
+ export async function isStrandMember(db, memberKey) {
785
+ return await memberStampId(db, memberKey) != null;
786
+ }
596
787
  /**
597
788
  * Admit a `Member` directly by manager signature — the sibling of the invite
598
789
  * path on `Member.Authorized`'s direct-manager branch.
@@ -608,18 +799,31 @@ async function memberStampId(db, memberKey) {
608
799
  *
609
800
  * @param db - The closed strand's database.
610
801
  * @param params - The admitting manager keypair and the new member key.
802
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
611
803
  * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
612
804
  * rolls back, leaving no `Member` row.
613
805
  */
614
- export async function addMemberByManager(db, params) {
806
+ export async function addMemberByManager(db, params, options) {
615
807
  const { managerKeyPair, memberKey } = params;
616
- const stampId = generateStrandStampId();
617
- const signature = signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64);
618
- await db.exec(`insert into Strand.Member (Key, StampId)
619
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
620
- values (?, ?)`, [managerKeyPair.publicKeyB64, signature, memberKey, stampId]);
808
+ await execStrandTransaction(db, memberAddByManagerStatement(params), options);
621
809
  log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
622
810
  }
811
+ /** The manager-signed `Strand.Member` insert behind {@link addMemberByManager}, under a fresh stamp. */
812
+ function memberAddByManagerStatement(params) {
813
+ const { managerKeyPair, memberKey } = params;
814
+ const stampId = generateStrandStampId();
815
+ return {
816
+ sql: `insert into Strand.Member (Key, StampId)
817
+ with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
818
+ values (:memberKey, :memberStampId);`,
819
+ params: {
820
+ memberAdmitterKey: managerKeyPair.publicKeyB64,
821
+ memberAdmitterSignature: signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64),
822
+ memberKey,
823
+ memberStampId: stampId,
824
+ },
825
+ };
826
+ }
623
827
  /**
624
828
  * Revoke a `Member` on the signature of an existing manager.
625
829
  *
@@ -656,27 +860,39 @@ export async function addMemberByManager(db, params) {
656
860
  * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
657
861
  * schema NOTE states).
658
862
  *
863
+ * Callers running inside a cadre runtime should follow this write with
864
+ * `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
865
+ * machines are refused at the network layer by the strand's revoked-peer gate
866
+ * (`strand-revocation-enforcer.ts`), which polls for membership changes because
867
+ * this bare `Database` handle raises no event the runtime can hook — so without
868
+ * that call the cut lands up to one poll interval (default 30 s) later.
869
+ *
659
870
  * @param db - The closed strand's database.
660
871
  * @param params - The revoking manager keypair and the target member key.
872
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
661
873
  * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
662
874
  * signer), `Member.NotAManager` rejects (the target still holds a Manager
663
875
  * row), or `Member.MinOneMember` rejects (the removal would empty the member
664
876
  * set); the whole delete+tombstone transaction rolls back.
665
877
  */
666
- export async function revokeMember(db, params) {
878
+ export async function revokeMember(db, params, options) {
667
879
  const { managerKeyPair, memberKey } = params;
668
880
  const stampId = await memberStampId(db, memberKey);
669
881
  if (stampId == null) {
670
882
  log('Member %s already absent; skipping revoke', memberKey);
671
883
  return;
672
884
  }
673
- const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
674
- await inStrandTransaction(db, async () => {
675
- await db.exec(`delete from Strand.Member
676
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
677
- where Key = ?`, [managerKeyPair.publicKeyB64, signature, memberKey]);
678
- await insertRevocation(db, 'Member', stampId, managerKeyPair);
679
- });
885
+ const removal = {
886
+ sql: `delete from Strand.Member
887
+ with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
888
+ where Key = :memberKey;`,
889
+ params: {
890
+ memberRemoverKey: managerKeyPair.publicKeyB64,
891
+ memberRemoverSignature: signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64),
892
+ memberKey,
893
+ },
894
+ };
895
+ await execStrandTransaction(db, combineStatements(removal, revocationStatement('Member', stampId, managerKeyPair)), options);
680
896
  log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
681
897
  }
682
898
  /**
@@ -693,12 +909,18 @@ export async function revokeMember(db, params) {
693
909
  * The same floors as {@link revokeMember} apply: a manager must resign first
694
910
  * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
695
911
  *
912
+ * Like {@link revokeMember}, a caller inside a cadre runtime should follow this
913
+ * with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
914
+ * makes the departing node notice its own departure promptly (the gate emits
915
+ * `strand:revoked` when it finds this node's own peer id revoked).
916
+ *
696
917
  * @param db - The closed strand's database.
697
918
  * @param params - The departing member's own keypair.
919
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
698
920
  * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
699
921
  * rejects; the whole delete+tombstone transaction rolls back.
700
922
  */
701
- export async function leaveStrand(db, params) {
923
+ export async function leaveStrand(db, params, options) {
702
924
  const { memberKeyPair } = params;
703
925
  const memberKey = memberKeyPair.publicKeyB64;
704
926
  const stampId = await memberStampId(db, memberKey);
@@ -706,13 +928,16 @@ export async function leaveStrand(db, params) {
706
928
  log('Member %s already absent; skipping leave', memberKey);
707
929
  return;
708
930
  }
709
- const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
710
- await inStrandTransaction(db, async () => {
711
- await db.exec(`delete from Strand.Member
712
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
713
- where Key = ?`, [signature, memberKey]);
714
- await insertRevocation(db, 'Member', stampId, memberKeyPair);
715
- });
931
+ const departure = {
932
+ sql: `delete from Strand.Member
933
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
934
+ where Key = :memberKey;`,
935
+ params: {
936
+ memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
937
+ memberKey,
938
+ },
939
+ };
940
+ await execStrandTransaction(db, combineStatements(departure, revocationStatement('Member', stampId, memberKeyPair)), options);
716
941
  log('Member %s left the strand', memberKey);
717
942
  }
718
943
  /**
@@ -745,10 +970,13 @@ export async function leaveStrand(db, params) {
745
970
  *
746
971
  * @param db - The closed strand's database (the member already exists).
747
972
  * @param params - The member's own keypair and the peer id to bind.
973
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
748
974
  * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
749
975
  * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
976
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
977
+ * transaction is open; nothing was written.
750
978
  */
751
- export async function registerMemberPeer(db, params) {
979
+ export async function registerMemberPeer(db, params, options) {
752
980
  const { memberKeyPair, peerId } = params;
753
981
  const memberKey = memberKeyPair.publicKeyB64;
754
982
  if (await memberPeerStampId(db, memberKey, peerId) != null) {
@@ -756,10 +984,17 @@ export async function registerMemberPeer(db, params) {
756
984
  return;
757
985
  }
758
986
  const stampId = generateStrandStampId();
759
- const signature = signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64);
760
- await db.exec(`insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
761
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
762
- values (?, ?, ?)`, [signature, memberKey, peerId, stampId]);
987
+ await execStrandTransaction(db, {
988
+ sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
989
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
990
+ values (:peerMemberKey, :peerId, :peerStampId);`,
991
+ params: {
992
+ peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
993
+ peerMemberKey: memberKey,
994
+ peerId,
995
+ peerStampId: stampId,
996
+ },
997
+ }, options);
763
998
  log('Registered MemberPeer (%s, %s)', memberKey, peerId);
764
999
  }
765
1000
  /**
@@ -866,10 +1101,13 @@ async function memberPeerStampId(db, memberKey, peerId) {
866
1101
  * @param db - The closed strand's database.
867
1102
  * @param params - Either the owning member's keypair, or a manager's keypair plus the
868
1103
  * target member key.
1104
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
869
1105
  * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
870
1106
  * is still present after the delete (see the point-lookup note below).
1107
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
1108
+ * transaction is open; nothing was written.
871
1109
  */
872
- export async function removeMemberPeer(db, params) {
1110
+ export async function removeMemberPeer(db, params, options) {
873
1111
  const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
874
1112
  const { peerId } = params;
875
1113
  const stampId = await memberPeerStampId(db, memberKey, peerId);
@@ -877,14 +1115,10 @@ export async function removeMemberPeer(db, params) {
877
1115
  log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
878
1116
  return;
879
1117
  }
880
- await inStrandTransaction(db, async () => {
881
- if ('memberKeyPair' in params) {
882
- await deleteOwnMemberPeer(db, params, stampId);
883
- }
884
- else {
885
- await deleteMemberPeerByManager(db, params, stampId);
886
- }
887
- });
1118
+ const removal = 'memberKeyPair' in params
1119
+ ? ownMemberPeerRemovalStatements(params, stampId)
1120
+ : memberPeerRemovalByManagerStatements(params, stampId);
1121
+ await execStrandTransaction(db, removal, options);
888
1122
  // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
889
1123
  // optimystic vtab module serves via a single-key seek that can MISS on a networked
890
1124
  // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
@@ -899,23 +1133,46 @@ export async function removeMemberPeer(db, params) {
899
1133
  log('Removed MemberPeer (%s, %s)', memberKey, peerId);
900
1134
  }
901
1135
  /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
902
- async function deleteOwnMemberPeer(db, params, stampId) {
1136
+ function ownMemberPeerRemovalStatements(params, stampId) {
903
1137
  const { memberKeyPair, peerId } = params;
904
1138
  const memberKey = memberKeyPair.publicKeyB64;
905
- const signature = signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64);
906
- await db.exec(`delete from Strand.MemberPeer
907
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
908
- where MemberKey = ? and PeerId = ?`, [signature, memberKey, peerId]);
909
- await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
1139
+ const removal = {
1140
+ sql: `delete from Strand.MemberPeer
1141
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1142
+ where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1143
+ params: {
1144
+ peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
1145
+ peerMemberKey: memberKey,
1146
+ peerId,
1147
+ },
1148
+ };
1149
+ return combineStatements(removal, revocationStatement('MemberPeer', stampId, memberKeyPair));
910
1150
  }
911
- /** The manager branch: a manager signs the manager-remove-tagged digest + tombstone. */
912
- async function deleteMemberPeerByManager(db, params, stampId) {
1151
+ /**
1152
+ * The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
1153
+ *
1154
+ * NOTE: clearing an ORPHANED binding (one whose member was already removed) also
1155
+ * forgets the NETWORK DENIAL of that peer id. The orphaned `MemberPeer` row IS
1156
+ * the record the strand revoked-peer gate derives its deny set from
1157
+ * (`strand-revocation-enforcer.ts`) — the `Strand.Revocation` tombstone keeps
1158
+ * only the stamp, not the peer id — so once the orphan is cleared, remaining
1159
+ * members' nodes resume answering and dialing that peer. Only clear a binding
1160
+ * that should never have existed, or a peer that is truly gone.
1161
+ */
1162
+ function memberPeerRemovalByManagerStatements(params, stampId) {
913
1163
  const { managerKeyPair, memberKey, peerId } = params;
914
- const signature = signStrandApproval(['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId], managerKeyPair.privateKeyB64);
915
- await db.exec(`delete from Strand.MemberPeer
916
- with context Signature = null, ManagerKey = ?, ManagerSignature = ?
917
- where MemberKey = ? and PeerId = ?`, [managerKeyPair.publicKeyB64, signature, memberKey, peerId]);
918
- await insertRevocation(db, 'MemberPeer', stampId, managerKeyPair);
1164
+ const removal = {
1165
+ sql: `delete from Strand.MemberPeer
1166
+ with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1167
+ where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1168
+ params: {
1169
+ peerManagerKey: managerKeyPair.publicKeyB64,
1170
+ peerManagerSignature: signStrandApproval(['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId], managerKeyPair.privateKeyB64),
1171
+ peerMemberKey: memberKey,
1172
+ peerId,
1173
+ },
1174
+ };
1175
+ return combineStatements(removal, revocationStatement('MemberPeer', stampId, managerKeyPair));
919
1176
  }
920
1177
  // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
921
1178
  /**
@@ -966,11 +1223,22 @@ async function managerRow(db, memberKey) {
966
1223
  *
967
1224
  * @param db - The closed strand's database (founder manager already seated).
968
1225
  * @param params - The authorizing manager's keypair and the new manager key.
1226
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
969
1227
  * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
970
1228
  * `newManagerKey` holds no `Member` row (admit it first, or use
971
1229
  * {@link admitManager}); the insert rolls back.
972
1230
  */
973
- export async function addManager(db, params) {
1231
+ export async function addManager(db, params, options) {
1232
+ const { byManagerKeyPair, newManagerKey } = params;
1233
+ const promotion = await managerAddStatement(db, params);
1234
+ await execStrandTransaction(db, promotion, options);
1235
+ log('Added manager %s (generation %d) by %s', newManagerKey, promotion.params.managerGeneration, byManagerKeyPair.publicKeyB64);
1236
+ }
1237
+ /**
1238
+ * The `Strand.Manager` insert behind {@link addManager}: reads the authorizer's generation,
1239
+ * seats the new manager one above it under a fresh stamp, and signs all three.
1240
+ */
1241
+ async function managerAddStatement(db, params) {
974
1242
  const { byManagerKeyPair, newManagerKey } = params;
975
1243
  const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
976
1244
  // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
@@ -980,11 +1248,18 @@ export async function addManager(db, params) {
980
1248
  // from outside this function, clamp or reject here rather than emitting a dead row.
981
1249
  const generation = authorizer == null ? 1 : authorizer.generation + 1;
982
1250
  const stampId = generateStrandStampId();
983
- const signature = signStrandApproval(['Strand.Manager', 'add', newManagerKey, generation, stampId], byManagerKeyPair.privateKeyB64);
984
- await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
985
- with context ManagerKey = ?, Signature = ?
986
- values (?, ?, ?)`, [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId]);
987
- log('Added manager %s (generation %d) by %s', newManagerKey, generation, byManagerKeyPair.publicKeyB64);
1251
+ return {
1252
+ sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1253
+ with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1254
+ values (:managerMemberKey, :managerGeneration, :managerStampId);`,
1255
+ params: {
1256
+ managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
1257
+ managerSignature: signStrandApproval(['Strand.Manager', 'add', newManagerKey, generation, stampId], byManagerKeyPair.privateKeyB64),
1258
+ managerMemberKey: newManagerKey,
1259
+ managerGeneration: generation,
1260
+ managerStampId: stampId,
1261
+ },
1262
+ };
988
1263
  }
989
1264
  /**
990
1265
  * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
@@ -1013,15 +1288,15 @@ export async function addManager(db, params) {
1013
1288
  *
1014
1289
  * @param db - The closed strand's database (founder manager already seated).
1015
1290
  * @param params - The authorizing manager's keypair and the key to admit + promote.
1291
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1016
1292
  * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1017
1293
  * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1018
1294
  */
1019
- export async function admitManager(db, params) {
1295
+ export async function admitManager(db, params, options) {
1020
1296
  const { byManagerKeyPair, newManagerKey } = params;
1021
- await inStrandTransaction(db, async () => {
1022
- await addMemberByManager(db, { managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1023
- await addManager(db, { byManagerKeyPair, newManagerKey });
1024
- });
1297
+ const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1298
+ const promotion = await managerAddStatement(db, params);
1299
+ await execStrandTransaction(db, combineStatements(admission, promotion), options);
1025
1300
  log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1026
1301
  }
1027
1302
  /**
@@ -1090,13 +1365,14 @@ export async function admitManager(db, params) {
1090
1365
  *
1091
1366
  * @param db - The closed strand's database.
1092
1367
  * @param params - The authorizing keypair and the target manager key.
1368
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1093
1369
  * @throws If the signer IS the target and it is the sole manager (before writing —
1094
1370
  * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1095
1371
  * rejects (a signer that is neither another existing manager nor the target
1096
1372
  * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1097
1373
  * whole delete+tombstone transaction rolls back.
1098
1374
  */
1099
- export async function removeManager(db, params) {
1375
+ export async function removeManager(db, params, options) {
1100
1376
  const { byManagerKeyPair, targetManagerKey } = params;
1101
1377
  const target = await managerRow(db, targetManagerKey);
1102
1378
  if (target == null) {
@@ -1109,14 +1385,26 @@ export async function removeManager(db, params) {
1109
1385
  + 'Manager table. To permanently freeze admission instead, call sealStrand.');
1110
1386
  }
1111
1387
  const signature = signStrandApproval(['Strand.Manager', tag, targetManagerKey, target.stampId], byManagerKeyPair.privateKeyB64);
1112
- await inStrandTransaction(db, async () => {
1113
- await db.exec(`delete from Strand.Manager
1114
- with context ManagerKey = ?, Signature = ?
1115
- where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
1116
- await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1117
- });
1388
+ await execStrandTransaction(db, managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId), options);
1118
1389
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1119
1390
  }
1391
+ /**
1392
+ * The `Strand.Manager` delete plus its tombstone, shared by {@link removeManager} and
1393
+ * {@link sealStrand}, which differ only in the action tag `signature` was minted under.
1394
+ */
1395
+ function managerRemovalStatements(signer, signature, targetManagerKey, targetStampId) {
1396
+ const removal = {
1397
+ sql: `delete from Strand.Manager
1398
+ with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1399
+ where MemberKey = :managerMemberKey;`,
1400
+ params: {
1401
+ managerRemoverKey: signer.publicKeyB64,
1402
+ managerRemoverSignature: signature,
1403
+ managerMemberKey: targetManagerKey,
1404
+ },
1405
+ };
1406
+ return combineStatements(removal, revocationStatement('Manager', targetStampId, signer));
1407
+ }
1120
1408
  /**
1121
1409
  * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1122
1410
  * membership.
@@ -1155,6 +1443,7 @@ export async function removeManager(db, params) {
1155
1443
  *
1156
1444
  * @param db - The closed strand's database.
1157
1445
  * @param params - The sole manager's own keypair.
1446
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1158
1447
  * @throws If more than one manager exists (the caller wants {@link removeManager}),
1159
1448
  * or the supplied keypair holds no `Manager` row while one exists, or the strand
1160
1449
  * holds no `Manager` row and has never retired one (not founded yet — see
@@ -1163,7 +1452,7 @@ export async function removeManager(db, params) {
1163
1452
  * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1164
1453
  * and {@link removeManager}'s absent-row no-op).
1165
1454
  */
1166
- export async function sealStrand(db, params) {
1455
+ export async function sealStrand(db, params, options) {
1167
1456
  const { managerKeyPair } = params;
1168
1457
  const managerCount = await strandTableCount(db, 'Manager');
1169
1458
  if (managerCount === 0) {
@@ -1187,12 +1476,7 @@ export async function sealStrand(db, params) {
1187
1476
  throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1188
1477
  }
1189
1478
  const signature = signStrandApproval(['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId], managerKeyPair.privateKeyB64);
1190
- await inStrandTransaction(db, async () => {
1191
- await db.exec(`delete from Strand.Manager
1192
- with context ManagerKey = ?, Signature = ?
1193
- where MemberKey = ?`, [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64]);
1194
- await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1195
- });
1479
+ await execStrandTransaction(db, managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId), options);
1196
1480
  log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1197
1481
  }
1198
1482
  /**