@serfab/cadre-core 0.13.0 → 1.1.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 (131) hide show
  1. package/README.md +20 -6
  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 +558 -80
  7. package/dist/cadre-node.js +1060 -170
  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 +299 -44
  16. package/dist/control-database.js +469 -69
  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 +103 -19
  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 +176 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store.js +2 -2
  31. package/dist/index.d.ts +14 -7
  32. package/dist/index.js +33 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/node-local-snapshot.d.ts +15 -7
  35. package/dist/node-local-snapshot.js +23 -8
  36. package/dist/node-local-snapshot.js.map +1 -1
  37. package/dist/peer-dial.d.ts +143 -0
  38. package/dist/peer-dial.js +176 -0
  39. package/dist/peer-dial.js.map +1 -0
  40. package/dist/relay-addrs.d.ts +90 -52
  41. package/dist/relay-addrs.js +91 -59
  42. package/dist/relay-addrs.js.map +1 -1
  43. package/dist/relay-reservation.d.ts +71 -3
  44. package/dist/relay-reservation.js +116 -8
  45. package/dist/relay-reservation.js.map +1 -1
  46. package/dist/seed-bootstrap.d.ts +30 -1
  47. package/dist/seed-bootstrap.js +62 -17
  48. package/dist/seed-bootstrap.js.map +1 -1
  49. package/dist/storage-scope.d.ts +62 -0
  50. package/dist/storage-scope.js +75 -0
  51. package/dist/storage-scope.js.map +1 -0
  52. package/dist/strand-database.d.ts +34 -16
  53. package/dist/strand-database.js +38 -24
  54. package/dist/strand-database.js.map +1 -1
  55. package/dist/strand-first-sync-gate.d.ts +152 -0
  56. package/dist/strand-first-sync-gate.js +208 -0
  57. package/dist/strand-first-sync-gate.js.map +1 -0
  58. package/dist/strand-formation-manager.d.ts +87 -5
  59. package/dist/strand-formation-manager.js +109 -5
  60. package/dist/strand-formation-manager.js.map +1 -1
  61. package/dist/strand-formation-protocol.d.ts +21 -1
  62. package/dist/strand-formation-protocol.js +30 -0
  63. package/dist/strand-formation-protocol.js.map +1 -1
  64. package/dist/strand-instance-manager.d.ts +243 -6
  65. package/dist/strand-instance-manager.js +470 -37
  66. package/dist/strand-instance-manager.js.map +1 -1
  67. package/dist/strand-member-key.d.ts +20 -12
  68. package/dist/strand-member-key.js +20 -12
  69. package/dist/strand-member-key.js.map +1 -1
  70. package/dist/strand-member-registry.js +3 -0
  71. package/dist/strand-member-registry.js.map +1 -1
  72. package/dist/strand-membership-reconciler.d.ts +361 -0
  73. package/dist/strand-membership-reconciler.js +548 -0
  74. package/dist/strand-membership-reconciler.js.map +1 -0
  75. package/dist/strand-membership-writer.d.ts +131 -11
  76. package/dist/strand-membership-writer.js +396 -134
  77. package/dist/strand-membership-writer.js.map +1 -1
  78. package/dist/strand-network-config.d.ts +46 -14
  79. package/dist/strand-network-config.js +79 -23
  80. package/dist/strand-network-config.js.map +1 -1
  81. package/dist/strand-solicitation.d.ts +10 -1
  82. package/dist/strand-solicitation.js +5 -0
  83. package/dist/strand-solicitation.js.map +1 -1
  84. package/dist/strand-wake-protocol.d.ts +11 -7
  85. package/dist/strand-wake-protocol.js +20 -52
  86. package/dist/strand-wake-protocol.js.map +1 -1
  87. package/dist/strand-watcher.js +6 -0
  88. package/dist/strand-watcher.js.map +1 -1
  89. package/dist/timed-step.d.ts +12 -0
  90. package/dist/timed-step.js +28 -0
  91. package/dist/timed-step.js.map +1 -0
  92. package/dist/timeout-scheduler.d.ts +19 -0
  93. package/dist/timeout-scheduler.js +22 -0
  94. package/dist/timeout-scheduler.js.map +1 -0
  95. package/dist/types.d.ts +262 -30
  96. package/dist/types.js.map +1 -1
  97. package/package.json +8 -8
  98. package/src/bootstrap-peer-store-file.ts +2 -2
  99. package/src/bootstrap-peer-store.ts +69 -27
  100. package/src/cadre-node.ts +7301 -6353
  101. package/src/control-authorization.ts +2 -1
  102. package/src/control-cohort.ts +122 -130
  103. package/src/control-database.ts +537 -69
  104. package/src/control-read-retry.ts +202 -167
  105. package/src/control-retry.ts +297 -190
  106. package/src/control-schema.ts +103 -19
  107. package/src/control-write-retry.ts +533 -383
  108. package/src/enrolled-machine-store.ts +2 -2
  109. package/src/index.ts +83 -6
  110. package/src/node-local-snapshot.ts +25 -8
  111. package/src/peer-dial.ts +215 -0
  112. package/src/relay-addrs.ts +458 -436
  113. package/src/relay-reservation.ts +951 -828
  114. package/src/seed-bootstrap.ts +76 -18
  115. package/src/storage-scope.ts +78 -0
  116. package/src/strand-database.ts +50 -27
  117. package/src/strand-first-sync-gate.ts +258 -0
  118. package/src/strand-formation-manager.ts +140 -5
  119. package/src/strand-formation-protocol.ts +43 -1
  120. package/src/strand-instance-manager.ts +577 -37
  121. package/src/strand-member-key.ts +20 -12
  122. package/src/strand-member-registry.ts +3 -0
  123. package/src/strand-membership-reconciler.ts +680 -0
  124. package/src/strand-membership-writer.ts +527 -189
  125. package/src/strand-network-config.ts +102 -24
  126. package/src/strand-solicitation.ts +16 -1
  127. package/src/strand-wake-protocol.ts +21 -66
  128. package/src/strand-watcher.ts +6 -0
  129. package/src/timed-step.ts +28 -0
  130. package/src/timeout-scheduler.ts +28 -0
  131. package/src/types.ts +1789 -1554
@@ -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';
130
+ }
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
+ }
138
146
  }
139
- await db.beginTransaction();
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
  *
@@ -665,25 +869,30 @@ export async function addMemberByManager(db, params) {
665
869
  *
666
870
  * @param db - The closed strand's database.
667
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}).
668
873
  * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
669
874
  * signer), `Member.NotAManager` rejects (the target still holds a Manager
670
875
  * row), or `Member.MinOneMember` rejects (the removal would empty the member
671
876
  * set); the whole delete+tombstone transaction rolls back.
672
877
  */
673
- export async function revokeMember(db, params) {
878
+ export async function revokeMember(db, params, options) {
674
879
  const { managerKeyPair, memberKey } = params;
675
880
  const stampId = await memberStampId(db, memberKey);
676
881
  if (stampId == null) {
677
882
  log('Member %s already absent; skipping revoke', memberKey);
678
883
  return;
679
884
  }
680
- const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
681
- await inStrandTransaction(db, async () => {
682
- await db.exec(`delete from Strand.Member
683
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
684
- where Key = ?`, [managerKeyPair.publicKeyB64, signature, memberKey]);
685
- await insertRevocation(db, 'Member', stampId, managerKeyPair);
686
- });
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);
687
896
  log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
688
897
  }
689
898
  /**
@@ -707,10 +916,11 @@ export async function revokeMember(db, params) {
707
916
  *
708
917
  * @param db - The closed strand's database.
709
918
  * @param params - The departing member's own keypair.
919
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
710
920
  * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
711
921
  * rejects; the whole delete+tombstone transaction rolls back.
712
922
  */
713
- export async function leaveStrand(db, params) {
923
+ export async function leaveStrand(db, params, options) {
714
924
  const { memberKeyPair } = params;
715
925
  const memberKey = memberKeyPair.publicKeyB64;
716
926
  const stampId = await memberStampId(db, memberKey);
@@ -718,13 +928,16 @@ export async function leaveStrand(db, params) {
718
928
  log('Member %s already absent; skipping leave', memberKey);
719
929
  return;
720
930
  }
721
- const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
722
- await inStrandTransaction(db, async () => {
723
- await db.exec(`delete from Strand.Member
724
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
725
- where Key = ?`, [signature, memberKey]);
726
- await insertRevocation(db, 'Member', stampId, memberKeyPair);
727
- });
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);
728
941
  log('Member %s left the strand', memberKey);
729
942
  }
730
943
  /**
@@ -757,10 +970,13 @@ export async function leaveStrand(db, params) {
757
970
  *
758
971
  * @param db - The closed strand's database (the member already exists).
759
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}).
760
974
  * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
761
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.
762
978
  */
763
- export async function registerMemberPeer(db, params) {
979
+ export async function registerMemberPeer(db, params, options) {
764
980
  const { memberKeyPair, peerId } = params;
765
981
  const memberKey = memberKeyPair.publicKeyB64;
766
982
  if (await memberPeerStampId(db, memberKey, peerId) != null) {
@@ -768,10 +984,17 @@ export async function registerMemberPeer(db, params) {
768
984
  return;
769
985
  }
770
986
  const stampId = generateStrandStampId();
771
- const signature = signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64);
772
- await db.exec(`insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
773
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
774
- 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);
775
998
  log('Registered MemberPeer (%s, %s)', memberKey, peerId);
776
999
  }
777
1000
  /**
@@ -878,10 +1101,13 @@ async function memberPeerStampId(db, memberKey, peerId) {
878
1101
  * @param db - The closed strand's database.
879
1102
  * @param params - Either the owning member's keypair, or a manager's keypair plus the
880
1103
  * target member key.
1104
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
881
1105
  * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
882
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.
883
1109
  */
884
- export async function removeMemberPeer(db, params) {
1110
+ export async function removeMemberPeer(db, params, options) {
885
1111
  const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
886
1112
  const { peerId } = params;
887
1113
  const stampId = await memberPeerStampId(db, memberKey, peerId);
@@ -889,14 +1115,10 @@ export async function removeMemberPeer(db, params) {
889
1115
  log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
890
1116
  return;
891
1117
  }
892
- await inStrandTransaction(db, async () => {
893
- if ('memberKeyPair' in params) {
894
- await deleteOwnMemberPeer(db, params, stampId);
895
- }
896
- else {
897
- await deleteMemberPeerByManager(db, params, stampId);
898
- }
899
- });
1118
+ const removal = 'memberKeyPair' in params
1119
+ ? ownMemberPeerRemovalStatements(params, stampId)
1120
+ : memberPeerRemovalByManagerStatements(params, stampId);
1121
+ await execStrandTransaction(db, removal, options);
900
1122
  // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
901
1123
  // optimystic vtab module serves via a single-key seek that can MISS on a networked
902
1124
  // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
@@ -911,14 +1133,20 @@ export async function removeMemberPeer(db, params) {
911
1133
  log('Removed MemberPeer (%s, %s)', memberKey, peerId);
912
1134
  }
913
1135
  /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
914
- async function deleteOwnMemberPeer(db, params, stampId) {
1136
+ function ownMemberPeerRemovalStatements(params, stampId) {
915
1137
  const { memberKeyPair, peerId } = params;
916
1138
  const memberKey = memberKeyPair.publicKeyB64;
917
- const signature = signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64);
918
- await db.exec(`delete from Strand.MemberPeer
919
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
920
- where MemberKey = ? and PeerId = ?`, [signature, memberKey, peerId]);
921
- 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));
922
1150
  }
923
1151
  /**
924
1152
  * The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
@@ -931,13 +1159,20 @@ async function deleteOwnMemberPeer(db, params, stampId) {
931
1159
  * members' nodes resume answering and dialing that peer. Only clear a binding
932
1160
  * that should never have existed, or a peer that is truly gone.
933
1161
  */
934
- async function deleteMemberPeerByManager(db, params, stampId) {
1162
+ function memberPeerRemovalByManagerStatements(params, stampId) {
935
1163
  const { managerKeyPair, memberKey, peerId } = params;
936
- const signature = signStrandApproval(['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId], managerKeyPair.privateKeyB64);
937
- await db.exec(`delete from Strand.MemberPeer
938
- with context Signature = null, ManagerKey = ?, ManagerSignature = ?
939
- where MemberKey = ? and PeerId = ?`, [managerKeyPair.publicKeyB64, signature, memberKey, peerId]);
940
- 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));
941
1176
  }
942
1177
  // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
943
1178
  /**
@@ -988,11 +1223,22 @@ async function managerRow(db, memberKey) {
988
1223
  *
989
1224
  * @param db - The closed strand's database (founder manager already seated).
990
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}).
991
1227
  * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
992
1228
  * `newManagerKey` holds no `Member` row (admit it first, or use
993
1229
  * {@link admitManager}); the insert rolls back.
994
1230
  */
995
- 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) {
996
1242
  const { byManagerKeyPair, newManagerKey } = params;
997
1243
  const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
998
1244
  // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
@@ -1002,11 +1248,18 @@ export async function addManager(db, params) {
1002
1248
  // from outside this function, clamp or reject here rather than emitting a dead row.
1003
1249
  const generation = authorizer == null ? 1 : authorizer.generation + 1;
1004
1250
  const stampId = generateStrandStampId();
1005
- const signature = signStrandApproval(['Strand.Manager', 'add', newManagerKey, generation, stampId], byManagerKeyPair.privateKeyB64);
1006
- await db.exec(`insert into Strand.Manager (MemberKey, Generation, StampId)
1007
- with context ManagerKey = ?, Signature = ?
1008
- values (?, ?, ?)`, [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId]);
1009
- 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
+ };
1010
1263
  }
1011
1264
  /**
1012
1265
  * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
@@ -1035,15 +1288,15 @@ export async function addManager(db, params) {
1035
1288
  *
1036
1289
  * @param db - The closed strand's database (founder manager already seated).
1037
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}).
1038
1292
  * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1039
1293
  * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1040
1294
  */
1041
- export async function admitManager(db, params) {
1295
+ export async function admitManager(db, params, options) {
1042
1296
  const { byManagerKeyPair, newManagerKey } = params;
1043
- await inStrandTransaction(db, async () => {
1044
- await addMemberByManager(db, { managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1045
- await addManager(db, { byManagerKeyPair, newManagerKey });
1046
- });
1297
+ const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1298
+ const promotion = await managerAddStatement(db, params);
1299
+ await execStrandTransaction(db, combineStatements(admission, promotion), options);
1047
1300
  log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1048
1301
  }
1049
1302
  /**
@@ -1112,13 +1365,14 @@ export async function admitManager(db, params) {
1112
1365
  *
1113
1366
  * @param db - The closed strand's database.
1114
1367
  * @param params - The authorizing keypair and the target manager key.
1368
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1115
1369
  * @throws If the signer IS the target and it is the sole manager (before writing —
1116
1370
  * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1117
1371
  * rejects (a signer that is neither another existing manager nor the target
1118
1372
  * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1119
1373
  * whole delete+tombstone transaction rolls back.
1120
1374
  */
1121
- export async function removeManager(db, params) {
1375
+ export async function removeManager(db, params, options) {
1122
1376
  const { byManagerKeyPair, targetManagerKey } = params;
1123
1377
  const target = await managerRow(db, targetManagerKey);
1124
1378
  if (target == null) {
@@ -1131,14 +1385,26 @@ export async function removeManager(db, params) {
1131
1385
  + 'Manager table. To permanently freeze admission instead, call sealStrand.');
1132
1386
  }
1133
1387
  const signature = signStrandApproval(['Strand.Manager', tag, targetManagerKey, target.stampId], byManagerKeyPair.privateKeyB64);
1134
- await inStrandTransaction(db, async () => {
1135
- await db.exec(`delete from Strand.Manager
1136
- with context ManagerKey = ?, Signature = ?
1137
- where MemberKey = ?`, [byManagerKeyPair.publicKeyB64, signature, targetManagerKey]);
1138
- await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1139
- });
1388
+ await execStrandTransaction(db, managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId), options);
1140
1389
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1141
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
+ }
1142
1408
  /**
1143
1409
  * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1144
1410
  * membership.
@@ -1177,6 +1443,7 @@ export async function removeManager(db, params) {
1177
1443
  *
1178
1444
  * @param db - The closed strand's database.
1179
1445
  * @param params - The sole manager's own keypair.
1446
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1180
1447
  * @throws If more than one manager exists (the caller wants {@link removeManager}),
1181
1448
  * or the supplied keypair holds no `Manager` row while one exists, or the strand
1182
1449
  * holds no `Manager` row and has never retired one (not founded yet — see
@@ -1185,7 +1452,7 @@ export async function removeManager(db, params) {
1185
1452
  * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1186
1453
  * and {@link removeManager}'s absent-row no-op).
1187
1454
  */
1188
- export async function sealStrand(db, params) {
1455
+ export async function sealStrand(db, params, options) {
1189
1456
  const { managerKeyPair } = params;
1190
1457
  const managerCount = await strandTableCount(db, 'Manager');
1191
1458
  if (managerCount === 0) {
@@ -1209,12 +1476,7 @@ export async function sealStrand(db, params) {
1209
1476
  throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1210
1477
  }
1211
1478
  const signature = signStrandApproval(['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId], managerKeyPair.privateKeyB64);
1212
- await inStrandTransaction(db, async () => {
1213
- await db.exec(`delete from Strand.Manager
1214
- with context ManagerKey = ?, Signature = ?
1215
- where MemberKey = ?`, [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64]);
1216
- await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1217
- });
1479
+ await execStrandTransaction(db, managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId), options);
1218
1480
  log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1219
1481
  }
1220
1482
  /**