@serfab/cadre-core 0.13.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 (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 +168 -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 +525 -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
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import type { Database } from '@quereus/quereus';
3
+ import type { Database, SqlValue } from '@quereus/quereus';
4
4
  import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
5
5
  import type { SAppConfig } from './types.js';
6
6
  import type { Ed25519KeyPair } from './ed25519-key.js';
@@ -126,48 +126,155 @@ export function generateStrandStampId(): string {
126
126
  return uint8ArrayToString(bytes, 'base64url');
127
127
  }
128
128
 
129
+ // ── Write batches (every membership write is one indivisible transaction) ──────
130
+
129
131
  /**
130
- * Run `fn` inside one explicit strand transaction: begin, fn, commit — with the
131
- * standard rollback-on-failure shape.
132
- *
133
- * Used wherever two `Strand.*` writes must land atomically: the invite-consume
134
- * pair (Member + ConsumedInvite) and every delete-plus-tombstone pair (the
135
- * `RevocationRecorded` constraints require the `Revocation` row in the SAME
136
- * transaction as the delete it retires).
137
- *
138
- * JOINS a caller-owned transaction instead of opening its own: Quereus's
139
- * `Database.beginTransaction()` hard-throws "Transaction already active" on any
140
- * nesting, and a caller composing several writers in one transaction (e.g. a
141
- * resign + revoke pair that must land atomically) is exactly the case where the
142
- * deferred constraints should fire once, at the CALLER's commit. In joined mode
143
- * commit/rollback belong to the caller, so failures simply propagate.
132
+ * A membership writer that runs only in a transaction of its own found another
133
+ * transaction already open on the strand database, and wrote nothing. Raised only
134
+ * under `joinOpenTransaction: false` (see {@link StrandWriteOptions}) — the background
135
+ * callers, which treat it as "the app is mid-transaction; try again later".
144
136
  */
145
- async function inStrandTransaction(db: Database, fn: () => Promise<void>): Promise<void> {
146
- if (!db.getAutocommit()) {
147
- await fn();
148
- return;
137
+ export class StrandTransactionBusyError extends Error {
138
+ constructor(options?: ErrorOptions) {
139
+ super(
140
+ 'Another transaction is open on the strand database. This membership write runs only in a '
141
+ + 'transaction of its own, so nothing was written; retry once that transaction closes.',
142
+ options,
143
+ );
144
+ this.name = 'StrandTransactionBusyError';
149
145
  }
150
- await db.beginTransaction();
146
+ }
147
+
148
+ /** Options every transactional membership writer accepts. */
149
+ export interface StrandWriteOptions {
150
+ /**
151
+ * Default true: when a transaction is already open on the database, run the writer's
152
+ * statements inside it. The caller composes several writers in one transaction (e.g. a
153
+ * resign + revoke pair that must land together) and owns its commit and rollback, so
154
+ * the deferred constraints fire once, at the caller's commit.
155
+ *
156
+ * Background callers (the membership reconciler, the unpublish binding cleanup) pass
157
+ * false: they share the database with the app and must never join a transaction
158
+ * someone else opened, so the writer throws {@link StrandTransactionBusyError} instead.
159
+ */
160
+ joinOpenTransaction?: boolean;
161
+ }
162
+
163
+ /**
164
+ * SQL statements, each terminated by `;`, plus the named parameters they bind. The unit
165
+ * the writers assemble a batch from: a writer does every read, signature, and
166
+ * `canonicalDatetime` first, and only then issues all its statements in ONE `db.exec`.
167
+ */
168
+ interface StrandStatements {
169
+ sql: string;
170
+ params: Record<string, SqlValue>;
171
+ }
172
+
173
+ /**
174
+ * Join statement fragments into one batch. Named parameters bind across the whole batch,
175
+ * so each fragment builder uses its own parameter names; a name bound twice would
176
+ * silently hand one fragment the other's value, so it throws instead.
177
+ */
178
+ function combineStatements(...fragments: StrandStatements[]): StrandStatements {
179
+ const params: Record<string, SqlValue> = {};
180
+ for (const fragment of fragments) {
181
+ for (const [name, value] of Object.entries(fragment.params)) {
182
+ if (name in params) {
183
+ throw new Error(`Strand write batch binds the parameter :${name} in two statements`);
184
+ }
185
+ params[name] = value;
186
+ }
187
+ }
188
+ return { sql: fragments.map((fragment) => fragment.sql).join('\n'), params };
189
+ }
190
+
191
+ /** Quereus's refusal of a `begin` issued while a transaction is already open. */
192
+ const NESTED_BEGIN_REFUSAL = /^Cannot begin transaction: already in a transaction$/;
193
+
194
+ function isNestedBeginRefusal(error: unknown): boolean {
195
+ return error instanceof Error && NESTED_BEGIN_REFUSAL.test(error.message);
196
+ }
197
+
198
+ /**
199
+ * Run `statements` as one indivisible transaction on the shared strand database.
200
+ *
201
+ * The whole transaction is ONE `db.exec` batch — `begin transaction; <statements> commit;`
202
+ * — because Quereus holds its exec mutex for a whole batch but releases it between
203
+ * separate calls, and any caller's statement that runs while an explicit transaction is
204
+ * open lands inside it. A `beginTransaction()` / `exec` / `commit()` sequence therefore
205
+ * swept the app's concurrent writes into a background writer's transaction, and lost them
206
+ * when that transaction failed.
207
+ *
208
+ * Whether a transaction is already open is decided by the batch's own `begin`, under the
209
+ * mutex, not by `getAutocommit()` beforehand: that reads false whenever another caller's
210
+ * autocommit statement is merely in flight, which would make a background writer refuse
211
+ * spuriously and a composing caller run its statements as separate autocommits.
212
+ *
213
+ * - The `begin` succeeds: the batch commits or fails as a unit. A failure at `commit`
214
+ * (deferred constraints, a refused optimystic commit) leaves nothing open. A failure at
215
+ * an earlier statement leaves the batch's transaction open, so it is rolled back here.
216
+ * - The `begin` is refused because a transaction is already open: nothing has run. With
217
+ * `joinOpenTransaction: false` that is a {@link StrandTransactionBusyError} (with the
218
+ * refusal as `cause`), and the open transaction — someone else's — is left alone.
219
+ * Otherwise the statements run inside it, and its owner commits or rolls back.
220
+ *
221
+ * NOTE: interim until Quereus ships a batch that rolls back before releasing its mutex —
222
+ * requested as `../quereus/tickets/plan/exec-batch-as-one-transaction.md`; switch to that
223
+ * API when a Quereus release carries it, which also retires the message match in
224
+ * {@link isNestedBeginRefusal}. Until then two residual windows remain. (1) A statement
225
+ * that fails before `commit` (a primary-key collision such as a sibling machine seating the
226
+ * same `Member` first, an immediate CHECK, a storage read error) releases the mutex with the
227
+ * batch's transaction still open: an app `exec` already queued behind the batch runs inside
228
+ * it, and the rollback below discards that app write although its `exec` resolved. The
229
+ * common failures (an `Invite` not replicated yet, expired, cancelled, sealed, and
230
+ * optimystic commit refusals) all fail at `commit` and leave nothing open. (2) The rollback
231
+ * decision reads `getAutocommit()` after the batch has released the mutex, so after a
232
+ * `commit`-time failure a queued app statement in flight reads as "still open" too; the
233
+ * rollback then runs after that statement's own commit and finds nothing to undo — unless
234
+ * an app `beginTransaction()` also slipped in between, which it would end.
235
+ */
236
+ async function execStrandTransaction(
237
+ db: Database,
238
+ statements: StrandStatements,
239
+ options?: StrandWriteOptions,
240
+ ): Promise<void> {
151
241
  try {
152
- await fn();
153
- await db.commit();
242
+ await db.exec(`begin transaction;\n${statements.sql}\ncommit;`, statements.params);
243
+ return;
154
244
  } catch (error) {
155
- // A failed commit() already tears down the transaction, so rollback() would
156
- // throw "No transaction active" and mask the real cause — swallow only that.
157
- try {
158
- await db.rollback();
159
- } catch (rollbackError) {
160
- log('Rollback after strand transaction failure was a no-op: %s', rollbackError);
245
+ if (!isNestedBeginRefusal(error)) {
246
+ await rollbackBatchLeftOpen(db, error);
247
+ throw error;
248
+ }
249
+ if (options?.joinOpenTransaction === false) {
250
+ throw new StrandTransactionBusyError({ cause: error });
161
251
  }
162
- throw error;
163
252
  }
253
+ await db.exec(statements.sql, statements.params);
164
254
  }
165
255
 
166
256
  /**
167
- * File the `Strand.Revocation` tombstone that retires a deleted row's StampId.
257
+ * Roll back the batch's own transaction when a statement before `commit` failed and left
258
+ * it open; a `commit`-time failure has already closed it. See the NOTE on
259
+ * {@link execStrandTransaction} for what this cannot tell apart.
260
+ */
261
+ async function rollbackBatchLeftOpen(db: Database, cause: unknown): Promise<void> {
262
+ if (db.getAutocommit()) {
263
+ return;
264
+ }
265
+ try {
266
+ await db.rollback();
267
+ log('Rolled back a strand write batch left open by a failed statement: %s', cause);
268
+ } catch (rollbackError) {
269
+ log('Rolling back a failed strand write batch failed (%s): %o', cause, rollbackError);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The `Strand.Revocation` tombstone insert that retires a deleted row's StampId.
168
275
  *
169
- * Runs inside the SAME transaction as the delete it accompanies — the guarded
170
- * tables' `RevocationRecorded` (deferred) requires the tombstone at commit, and
276
+ * Always batched with the delete it accompanies — the guarded tables'
277
+ * `RevocationRecorded` (deferred) requires the tombstone at commit, and
171
278
  * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
172
279
  * the pair only ever lands together.
173
280
  *
@@ -178,24 +285,26 @@ async function inStrandTransaction(db: Database, fn: () => Promise<void>): Promi
178
285
  * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
179
286
  * context is non-null — both fields are always supplied.
180
287
  *
181
- * @param db - The strand database, INSIDE an open transaction.
182
288
  * @param tableName - Which guarded table the stamp belonged to.
183
289
  * @param stampId - The retired stamp.
184
290
  * @param retiree - The committed member filing the tombstone (the delete's own signer).
185
291
  */
186
- async function insertRevocation(
187
- db: Database,
292
+ function revocationStatement(
188
293
  tableName: 'Member' | 'Manager' | 'MemberPeer',
189
294
  stampId: string,
190
295
  retiree: Ed25519KeyPair,
191
- ): Promise<void> {
192
- const signature = signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64);
193
- await db.exec(
194
- `insert into Strand.Revocation (TableName, StampId)
195
- with context MemberKey = ?, Signature = ?
196
- values (?, ?)`,
197
- [retiree.publicKeyB64, signature, tableName, stampId],
198
- );
296
+ ): StrandStatements {
297
+ return {
298
+ sql: `insert into Strand.Revocation (TableName, StampId)
299
+ with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
300
+ values (:revocationTable, :revocationStampId);`,
301
+ params: {
302
+ revocationSignerKey: retiree.publicKeyB64,
303
+ revocationSignature: signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64),
304
+ revocationTable: tableName,
305
+ revocationStampId: stampId,
306
+ },
307
+ };
199
308
  }
200
309
 
201
310
  /** Parameters for {@link bootstrapFounderMembership}. */
@@ -212,6 +321,38 @@ export interface FounderBootstrapParams {
212
321
  * Required for a closed strand; ignored for an open strand.
213
322
  */
214
323
  founderKeyPair?: Ed25519KeyPair;
324
+ /**
325
+ * The public key derived from the strand row's SHARED `MemberPrivateKey` (the read
326
+ * secret every joining party holds). When supplied for a closed strand, a
327
+ * `Strand.Member` or `Strand.Manager` row holding it refuses the bootstrap with
328
+ * {@link PreSplitStrandIdentityError} (see {@link assertNotPreSplitStrand}): nothing
329
+ * seats that key since the per-party identity split, so its presence is exactly the
330
+ * fingerprint of a strand founded before it. Omitted, no such check runs (a caller that
331
+ * holds no shared key has nothing to compare against). Ignored for an open strand.
332
+ */
333
+ sharedMemberPublicKey?: string;
334
+ }
335
+
336
+ /**
337
+ * A closed strand founded BEFORE the per-party identity split: its founding
338
+ * `Strand.Member` (and, unless handed on, `Strand.Manager`) is the key derived from the
339
+ * shared `MemberPrivateKey`, which every joining party holds, so any member can sign as
340
+ * the founder. There is no repair — rewriting the membership cannot be trusted, since any
341
+ * joiner could already have admitted or revoked anyone, or could race the rewrite — so
342
+ * the strand must be recreated. Thrown by {@link assertNotPreSplitStrand} (a founder
343
+ * bootstrap, or membership-invite issuance); the formation layer maps it to a
344
+ * non-retryable rejection.
345
+ */
346
+ export class PreSplitStrandIdentityError extends Error {
347
+ constructor(readonly strandId: string) {
348
+ super(
349
+ `Closed strand ${strandId} was founded before per-party strand identity: its founding ` +
350
+ 'member key is derived from the shared MemberPrivateKey that every joining party holds, ' +
351
+ 'so any member can act as its founder. It cannot be repaired — recreate the strand ' +
352
+ '(unpublish it and found a new one).',
353
+ );
354
+ this.name = 'PreSplitStrandIdentityError';
355
+ }
215
356
  }
216
357
 
217
358
  /**
@@ -240,6 +381,13 @@ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Mana
240
381
  * scale; if membership churn ever makes that table large — the growth the
241
382
  * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
242
383
  * — this wants a stored "founding closed" marker rather than a scan per call.
384
+ *
385
+ * NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
386
+ * written, so this node does not hold its block, and the storage layer consults the
387
+ * block's cohort on every read of a block it does not hold — this scan and every
388
+ * `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
389
+ * membership writes are rare; if they become frequent, file a singleton marker row in
390
+ * the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
243
391
  */
244
392
  async function strandHasManagerRevocation(db: Database): Promise<boolean> {
245
393
  for await (const row of db.eval('select TableName from Strand.Revocation')) {
@@ -354,6 +502,26 @@ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, str
354
502
  log('Inserted founding Manager for strand %s', strandId);
355
503
  }
356
504
 
505
+ /**
506
+ * Refuse a closed strand whose membership still holds the key derived from the shared
507
+ * `MemberPrivateKey` (see {@link FounderBootstrapParams.sharedMemberPublicKey}). `Member`
508
+ * is the primary probe — every manager is a member (`Manager.MemberExists`), so it also
509
+ * catches a pre-split founder who handed management to another key and resigned, or a
510
+ * sealed pre-split strand. `Manager` is probed too, for the partition case the schema
511
+ * notes on `MemberExists` (a Manager row converged without its Member row). Cannot
512
+ * misfire: nothing seats the shared-derived key since the split, and rows not yet loaded
513
+ * read as absent.
514
+ *
515
+ * @throws {PreSplitStrandIdentityError} when a `Strand.Member` or `Strand.Manager` row is
516
+ * the shared-derived key.
517
+ */
518
+ export async function assertNotPreSplitStrand(db: Database, strandId: string, sharedMemberPublicKey: string): Promise<void> {
519
+ if (await memberStampId(db, sharedMemberPublicKey) !== undefined
520
+ || await managerRow(db, sharedMemberPublicKey) !== undefined) {
521
+ throw new PreSplitStrandIdentityError(strandId);
522
+ }
523
+ }
524
+
357
525
  /**
358
526
  * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
359
527
  *
@@ -375,13 +543,20 @@ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, str
375
543
  * Manager could never admit anyone, so failing loudly here (which propagates out
376
544
  * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
377
545
  *
546
+ * A closed strand whose membership holds `sharedMemberPublicKey` throws
547
+ * {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
548
+ * guards would otherwise skip over its pre-split founding rows and leave the shared
549
+ * key seated.
550
+ *
378
551
  * @param db - The strand's Quereus database (schema already applied).
379
552
  * @param params - Strand id/type, the sApp config for the Header, and (closed only)
380
- * the derived founder keypair.
553
+ * the derived founder keypair and the shared-derived public key to refuse.
381
554
  * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
555
+ * @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
556
+ * `Strand.Manager` row is `sharedMemberPublicKey`.
382
557
  */
383
558
  export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
384
- const { strandId, type, founderKeyPair } = params;
559
+ const { strandId, type, founderKeyPair, sharedMemberPublicKey } = params;
385
560
  log('Founder bootstrap for strand %s (type %s)', strandId, type);
386
561
 
387
562
  // Validate BEFORE writing anything: a closed strand with no founder key must
@@ -389,10 +564,13 @@ export async function bootstrapFounderMembership(db: Database, params: FounderBo
389
564
  // (such a strand could never admit anyone).
390
565
  if (type === 'c' && !founderKeyPair) {
391
566
  throw new Error(
392
- `Cannot bootstrap closed strand ${strandId}: no founder key pair derived from MemberPrivateKey. ` +
393
- 'A closed strand needs a founding Member + Manager or it can never admit members.',
567
+ `Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
568
+ 'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.',
394
569
  );
395
570
  }
571
+ if (type === 'c' && sharedMemberPublicKey !== undefined) {
572
+ await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
573
+ }
396
574
 
397
575
  // Header is written for every strand; Member/Manager are closed-only.
398
576
  await insertHeaderIfAbsent(db, params);
@@ -540,41 +718,90 @@ export interface ConsumeInviteParams {
540
718
  * @param db - The closed strand's database.
541
719
  * @param params - The invite key/secret, the joining member's public key, and an
542
720
  * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
721
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
543
722
  * @throws If any constraint rejects (bad signature, missing invite, or an expired
544
723
  * invite); the whole transaction rolls back (neither the `Member` nor the
545
724
  * `ConsumedInvite` row survives).
725
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
726
+ * transaction is open; nothing was written.
546
727
  */
547
- export async function consumeInvite(db: Database, params: ConsumeInviteParams): Promise<void> {
548
- const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
549
-
550
- const usagePayload = `${inviteKey}|${memberKey}`;
551
- const inviteSignature = signStrandPayload(usagePayload, invitePrivateKey);
552
-
553
- // Canonicalise "now" the same way issueInvite canonicalises Expiration, so the
554
- // schema's `I.Expiration > context.Now` compares like-for-like canonical strings.
555
- // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
556
- const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
728
+ export async function consumeInvite(
729
+ db: Database,
730
+ params: ConsumeInviteParams,
731
+ options?: StrandWriteOptions,
732
+ ): Promise<void> {
733
+ const { inviteKey, memberKey } = params;
734
+ // Member first — admitted by the deferred invite branch (the matching ConsumedInvite
735
+ // after it), so no manager signature is supplied.
736
+ const member: StrandStatements = {
737
+ sql: `insert into Strand.Member (Key, StampId)
738
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
739
+ values (:memberKey, :memberStampId);`,
740
+ params: { memberKey, memberStampId: generateStrandStampId() },
741
+ };
742
+ await execStrandTransaction(db, combineStatements(member, await consumedInviteStatement(db, params)), options);
743
+ log('Consumed invite %s -> member %s', inviteKey, memberKey);
744
+ }
557
745
 
558
- await inStrandTransaction(db, async () => {
559
- // 1. Member — admitted by the deferred invite branch (the matching
560
- // ConsumedInvite below), so no manager signature is supplied.
561
- await db.exec(
562
- `insert into Strand.Member (Key, StampId)
563
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
564
- values (?, ?)`,
565
- [memberKey, generateStrandStampId()],
566
- );
746
+ /**
747
+ * The `Strand.ConsumedInvite` marker row insert — the possession-plus-freshness proof
748
+ * shared by {@link consumeInvite} (batched after its `Member` insert) and
749
+ * {@link burnInvite} (alone, against an already-seated member).
750
+ *
751
+ * The invite signature covers `InviteKey || '|' || MemberKey` (the `ValidUsage` gate),
752
+ * and "now" is canonicalised the same way {@link issueInvite} canonicalises
753
+ * `Expiration`, so the schema's `I.Expiration > context.Now` compares like-for-like
754
+ * canonical strings. Plain runtime Date.now() — the tess Workflow restriction is on
755
+ * scripts, not libs.
756
+ */
757
+ async function consumedInviteStatement(db: Database, params: ConsumeInviteParams): Promise<StrandStatements> {
758
+ const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
759
+ return {
760
+ sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
761
+ with context InviteSignature = :consumedSignature, Now = :consumedNow
762
+ values (:consumedInviteKey, :consumedMemberKey);`,
763
+ params: {
764
+ consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
765
+ consumedNow: await canonicalDatetime(db, nowMs ?? Date.now()),
766
+ consumedInviteKey: inviteKey,
767
+ consumedMemberKey: memberKey,
768
+ },
769
+ };
770
+ }
567
771
 
568
- // 2. ConsumedInvite — proves possession of the invite private key and that the
569
- // invite has not expired (NotExpired gate against the canonical Now).
570
- await db.exec(
571
- `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
572
- with context InviteSignature = ?, Now = ?
573
- values (?, ?)`,
574
- [inviteSignature, nowCanonical, inviteKey, memberKey],
575
- );
576
- });
577
- log('Consumed invite %s -> member %s', inviteKey, memberKey);
772
+ /**
773
+ * Burn an invitation against an ALREADY-SEATED member: insert the
774
+ * `Strand.ConsumedInvite` row alone, naming the existing `Member.Key`, so the bearer
775
+ * credential can never be spent by anyone else.
776
+ *
777
+ * The already-member arm of the bring-up membership reconciler
778
+ * (`strand-membership-reconciler.ts`): a machine that finds its party's `Member` row
779
+ * already present (a sibling machine redeemed the invitation's twin first, or a
780
+ * manager admitted the party directly) may still hold an UNSPENT formation-delivered
781
+ * invitation — a bearer credential whoever presents can join with. Every
782
+ * `ConsumedInvite` constraint is satisfiable without a same-transaction `Member`
783
+ * insert (`MemberExists` reads the live table), so this is one statement whose
784
+ * deferred checks fire at its own commit. It seats nobody now or later:
785
+ * `Member.Authorized`'s invite branch requires a same-transaction FRESH consumption,
786
+ * so the row it leaves is exactly as inert as any other spent invitation's.
787
+ *
788
+ * @param db - The closed strand's database (the member already exists).
789
+ * @param params - The invite key/secret and the EXISTING member's public key, plus the
790
+ * optional `nowMs` instant for the expiry gate (default `Date.now()`).
791
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
792
+ * @throws If any constraint rejects — already consumed (the `InviteKey` primary key),
793
+ * cancelled, expired, or a sealed strand. Callers treat a burn failure as "already
794
+ * dead" and log rather than retry.
795
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
796
+ * transaction is open; nothing was tried, so the invitation is exactly as live as before.
797
+ */
798
+ export async function burnInvite(
799
+ db: Database,
800
+ params: ConsumeInviteParams,
801
+ options?: StrandWriteOptions,
802
+ ): Promise<void> {
803
+ await execStrandTransaction(db, await consumedInviteStatement(db, params), options);
804
+ log('Burned invite %s against existing member %s', params.inviteKey, params.memberKey);
578
805
  }
579
806
 
580
807
  /**
@@ -759,6 +986,18 @@ async function memberStampId(db: Database, memberKey: string): Promise<string |
759
986
  return undefined;
760
987
  }
761
988
 
989
+ /**
990
+ * Whether `memberKey` holds a live `Strand.Member` row, as seen by THIS database
991
+ * instance — the membership probe behind the bring-up reconciler
992
+ * (`strand-membership-reconciler.ts`). Reads via {@link memberStampId}'s unfiltered
993
+ * scan + JavaScript compare (the {@link scanMemberPeers} argument for why a PK point
994
+ * lookup is not reliable on a networked strand). Local visibility only: `false` can
995
+ * mean "not replicated here yet", never a durable verdict about the strand.
996
+ */
997
+ export async function isStrandMember(db: Database, memberKey: string): Promise<boolean> {
998
+ return await memberStampId(db, memberKey) != null;
999
+ }
1000
+
762
1001
  /** Parameters for {@link addMemberByManager}. */
763
1002
  export interface AddMemberByManagerParams {
764
1003
  /**
@@ -786,22 +1025,37 @@ export interface AddMemberByManagerParams {
786
1025
  *
787
1026
  * @param db - The closed strand's database.
788
1027
  * @param params - The admitting manager keypair and the new member key.
1028
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
789
1029
  * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
790
1030
  * rolls back, leaving no `Member` row.
791
1031
  */
792
- export async function addMemberByManager(db: Database, params: AddMemberByManagerParams): Promise<void> {
1032
+ export async function addMemberByManager(
1033
+ db: Database,
1034
+ params: AddMemberByManagerParams,
1035
+ options?: StrandWriteOptions,
1036
+ ): Promise<void> {
793
1037
  const { managerKeyPair, memberKey } = params;
794
- const stampId = generateStrandStampId();
795
- const signature = signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64);
796
- await db.exec(
797
- `insert into Strand.Member (Key, StampId)
798
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
799
- values (?, ?)`,
800
- [managerKeyPair.publicKeyB64, signature, memberKey, stampId],
801
- );
1038
+ await execStrandTransaction(db, memberAddByManagerStatement(params), options);
802
1039
  log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
803
1040
  }
804
1041
 
1042
+ /** The manager-signed `Strand.Member` insert behind {@link addMemberByManager}, under a fresh stamp. */
1043
+ function memberAddByManagerStatement(params: AddMemberByManagerParams): StrandStatements {
1044
+ const { managerKeyPair, memberKey } = params;
1045
+ const stampId = generateStrandStampId();
1046
+ return {
1047
+ sql: `insert into Strand.Member (Key, StampId)
1048
+ with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
1049
+ values (:memberKey, :memberStampId);`,
1050
+ params: {
1051
+ memberAdmitterKey: managerKeyPair.publicKeyB64,
1052
+ memberAdmitterSignature: signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64),
1053
+ memberKey,
1054
+ memberStampId: stampId,
1055
+ },
1056
+ };
1057
+ }
1058
+
805
1059
  /** Parameters for {@link revokeMember}. */
806
1060
  export interface RevokeMemberParams {
807
1061
  /**
@@ -861,28 +1115,38 @@ export interface RevokeMemberParams {
861
1115
  *
862
1116
  * @param db - The closed strand's database.
863
1117
  * @param params - The revoking manager keypair and the target member key.
1118
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
864
1119
  * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
865
1120
  * signer), `Member.NotAManager` rejects (the target still holds a Manager
866
1121
  * row), or `Member.MinOneMember` rejects (the removal would empty the member
867
1122
  * set); the whole delete+tombstone transaction rolls back.
868
1123
  */
869
- export async function revokeMember(db: Database, params: RevokeMemberParams): Promise<void> {
1124
+ export async function revokeMember(
1125
+ db: Database,
1126
+ params: RevokeMemberParams,
1127
+ options?: StrandWriteOptions,
1128
+ ): Promise<void> {
870
1129
  const { managerKeyPair, memberKey } = params;
871
1130
  const stampId = await memberStampId(db, memberKey);
872
1131
  if (stampId == null) {
873
1132
  log('Member %s already absent; skipping revoke', memberKey);
874
1133
  return;
875
1134
  }
876
- const signature = signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64);
877
- await inStrandTransaction(db, async () => {
878
- await db.exec(
879
- `delete from Strand.Member
880
- with context ManagerKey = ?, ManagerSignature = ?, MemberSignature = null
881
- where Key = ?`,
882
- [managerKeyPair.publicKeyB64, signature, memberKey],
883
- );
884
- await insertRevocation(db, 'Member', stampId, managerKeyPair);
885
- });
1135
+ const removal: StrandStatements = {
1136
+ sql: `delete from Strand.Member
1137
+ with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
1138
+ where Key = :memberKey;`,
1139
+ params: {
1140
+ memberRemoverKey: managerKeyPair.publicKeyB64,
1141
+ memberRemoverSignature: signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64),
1142
+ memberKey,
1143
+ },
1144
+ };
1145
+ await execStrandTransaction(
1146
+ db,
1147
+ combineStatements(removal, revocationStatement('Member', stampId, managerKeyPair)),
1148
+ options,
1149
+ );
886
1150
  log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
887
1151
  }
888
1152
 
@@ -921,10 +1185,15 @@ export interface LeaveStrandParams {
921
1185
  *
922
1186
  * @param db - The closed strand's database.
923
1187
  * @param params - The departing member's own keypair.
1188
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
924
1189
  * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
925
1190
  * rejects; the whole delete+tombstone transaction rolls back.
926
1191
  */
927
- export async function leaveStrand(db: Database, params: LeaveStrandParams): Promise<void> {
1192
+ export async function leaveStrand(
1193
+ db: Database,
1194
+ params: LeaveStrandParams,
1195
+ options?: StrandWriteOptions,
1196
+ ): Promise<void> {
928
1197
  const { memberKeyPair } = params;
929
1198
  const memberKey = memberKeyPair.publicKeyB64;
930
1199
  const stampId = await memberStampId(db, memberKey);
@@ -932,16 +1201,20 @@ export async function leaveStrand(db: Database, params: LeaveStrandParams): Prom
932
1201
  log('Member %s already absent; skipping leave', memberKey);
933
1202
  return;
934
1203
  }
935
- const signature = signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64);
936
- await inStrandTransaction(db, async () => {
937
- await db.exec(
938
- `delete from Strand.Member
939
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = ?
940
- where Key = ?`,
941
- [signature, memberKey],
942
- );
943
- await insertRevocation(db, 'Member', stampId, memberKeyPair);
944
- });
1204
+ const departure: StrandStatements = {
1205
+ sql: `delete from Strand.Member
1206
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
1207
+ where Key = :memberKey;`,
1208
+ params: {
1209
+ memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
1210
+ memberKey,
1211
+ },
1212
+ };
1213
+ await execStrandTransaction(
1214
+ db,
1215
+ combineStatements(departure, revocationStatement('Member', stampId, memberKeyPair)),
1216
+ options,
1217
+ );
945
1218
  log('Member %s left the strand', memberKey);
946
1219
  }
947
1220
 
@@ -993,10 +1266,17 @@ export interface RegisterMemberPeerParams {
993
1266
  *
994
1267
  * @param db - The closed strand's database (the member already exists).
995
1268
  * @param params - The member's own keypair and the peer id to bind.
1269
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
996
1270
  * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
997
1271
  * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
1272
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
1273
+ * transaction is open; nothing was written.
998
1274
  */
999
- export async function registerMemberPeer(db: Database, params: RegisterMemberPeerParams): Promise<void> {
1275
+ export async function registerMemberPeer(
1276
+ db: Database,
1277
+ params: RegisterMemberPeerParams,
1278
+ options?: StrandWriteOptions,
1279
+ ): Promise<void> {
1000
1280
  const { memberKeyPair, peerId } = params;
1001
1281
  const memberKey = memberKeyPair.publicKeyB64;
1002
1282
 
@@ -1006,16 +1286,17 @@ export async function registerMemberPeer(db: Database, params: RegisterMemberPee
1006
1286
  }
1007
1287
 
1008
1288
  const stampId = generateStrandStampId();
1009
- const signature = signStrandApproval(
1010
- ['Strand.MemberPeer', 'add', memberKey, peerId, stampId],
1011
- memberKeyPair.privateKeyB64,
1012
- );
1013
- await db.exec(
1014
- `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
1015
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
1016
- values (?, ?, ?)`,
1017
- [signature, memberKey, peerId, stampId],
1018
- );
1289
+ await execStrandTransaction(db, {
1290
+ sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
1291
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1292
+ values (:peerMemberKey, :peerId, :peerStampId);`,
1293
+ params: {
1294
+ peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
1295
+ peerMemberKey: memberKey,
1296
+ peerId,
1297
+ peerStampId: stampId,
1298
+ },
1299
+ }, options);
1019
1300
  log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1020
1301
  }
1021
1302
 
@@ -1167,10 +1448,17 @@ export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByMan
1167
1448
  * @param db - The closed strand's database.
1168
1449
  * @param params - Either the owning member's keypair, or a manager's keypair plus the
1169
1450
  * target member key.
1451
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1170
1452
  * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
1171
1453
  * is still present after the delete (see the point-lookup note below).
1454
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
1455
+ * transaction is open; nothing was written.
1172
1456
  */
1173
- export async function removeMemberPeer(db: Database, params: RemoveMemberPeerParams): Promise<void> {
1457
+ export async function removeMemberPeer(
1458
+ db: Database,
1459
+ params: RemoveMemberPeerParams,
1460
+ options?: StrandWriteOptions,
1461
+ ): Promise<void> {
1174
1462
  const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
1175
1463
  const { peerId } = params;
1176
1464
 
@@ -1180,13 +1468,10 @@ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerPar
1180
1468
  return;
1181
1469
  }
1182
1470
 
1183
- await inStrandTransaction(db, async () => {
1184
- if ('memberKeyPair' in params) {
1185
- await deleteOwnMemberPeer(db, params, stampId);
1186
- } else {
1187
- await deleteMemberPeerByManager(db, params, stampId);
1188
- }
1189
- });
1471
+ const removal = 'memberKeyPair' in params
1472
+ ? ownMemberPeerRemovalStatements(params, stampId)
1473
+ : memberPeerRemovalByManagerStatements(params, stampId);
1474
+ await execStrandTransaction(db, removal, options);
1190
1475
 
1191
1476
  // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
1192
1477
  // optimystic vtab module serves via a single-key seek that can MISS on a networked
@@ -1203,20 +1488,20 @@ export async function removeMemberPeer(db: Database, params: RemoveMemberPeerPar
1203
1488
  }
1204
1489
 
1205
1490
  /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
1206
- async function deleteOwnMemberPeer(db: Database, params: RemoveOwnPeerParams, stampId: string): Promise<void> {
1491
+ function ownMemberPeerRemovalStatements(params: RemoveOwnPeerParams, stampId: string): StrandStatements {
1207
1492
  const { memberKeyPair, peerId } = params;
1208
1493
  const memberKey = memberKeyPair.publicKeyB64;
1209
- const signature = signStrandApproval(
1210
- ['Strand.MemberPeer', 'remove', memberKey, peerId, stampId],
1211
- memberKeyPair.privateKeyB64,
1212
- );
1213
- await db.exec(
1214
- `delete from Strand.MemberPeer
1215
- with context Signature = ?, ManagerKey = null, ManagerSignature = null
1216
- where MemberKey = ? and PeerId = ?`,
1217
- [signature, memberKey, peerId],
1218
- );
1219
- await insertRevocation(db, 'MemberPeer', stampId, memberKeyPair);
1494
+ const removal: StrandStatements = {
1495
+ sql: `delete from Strand.MemberPeer
1496
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1497
+ where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1498
+ params: {
1499
+ peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
1500
+ peerMemberKey: memberKey,
1501
+ peerId,
1502
+ },
1503
+ };
1504
+ return combineStatements(removal, revocationStatement('MemberPeer', stampId, memberKeyPair));
1220
1505
  }
1221
1506
 
1222
1507
  /**
@@ -1230,23 +1515,26 @@ async function deleteOwnMemberPeer(db: Database, params: RemoveOwnPeerParams, st
1230
1515
  * members' nodes resume answering and dialing that peer. Only clear a binding
1231
1516
  * that should never have existed, or a peer that is truly gone.
1232
1517
  */
1233
- async function deleteMemberPeerByManager(
1234
- db: Database,
1518
+ function memberPeerRemovalByManagerStatements(
1235
1519
  params: RemoveMemberPeerByManagerParams,
1236
1520
  stampId: string,
1237
- ): Promise<void> {
1521
+ ): StrandStatements {
1238
1522
  const { managerKeyPair, memberKey, peerId } = params;
1239
- const signature = signStrandApproval(
1240
- ['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId],
1241
- managerKeyPair.privateKeyB64,
1242
- );
1243
- await db.exec(
1244
- `delete from Strand.MemberPeer
1245
- with context Signature = null, ManagerKey = ?, ManagerSignature = ?
1246
- where MemberKey = ? and PeerId = ?`,
1247
- [managerKeyPair.publicKeyB64, signature, memberKey, peerId],
1248
- );
1249
- await insertRevocation(db, 'MemberPeer', stampId, managerKeyPair);
1523
+ const removal: StrandStatements = {
1524
+ sql: `delete from Strand.MemberPeer
1525
+ with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1526
+ where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1527
+ params: {
1528
+ peerManagerKey: managerKeyPair.publicKeyB64,
1529
+ peerManagerSignature: signStrandApproval(
1530
+ ['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId],
1531
+ managerKeyPair.privateKeyB64,
1532
+ ),
1533
+ peerMemberKey: memberKey,
1534
+ peerId,
1535
+ },
1536
+ };
1537
+ return combineStatements(removal, revocationStatement('MemberPeer', stampId, managerKeyPair));
1250
1538
  }
1251
1539
 
1252
1540
  // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
@@ -1314,11 +1602,28 @@ export interface AddManagerParams {
1314
1602
  *
1315
1603
  * @param db - The closed strand's database (founder manager already seated).
1316
1604
  * @param params - The authorizing manager's keypair and the new manager key.
1605
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1317
1606
  * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
1318
1607
  * `newManagerKey` holds no `Member` row (admit it first, or use
1319
1608
  * {@link admitManager}); the insert rolls back.
1320
1609
  */
1321
- export async function addManager(db: Database, params: AddManagerParams): Promise<void> {
1610
+ export async function addManager(
1611
+ db: Database,
1612
+ params: AddManagerParams,
1613
+ options?: StrandWriteOptions,
1614
+ ): Promise<void> {
1615
+ const { byManagerKeyPair, newManagerKey } = params;
1616
+ const promotion = await managerAddStatement(db, params);
1617
+ await execStrandTransaction(db, promotion, options);
1618
+ log('Added manager %s (generation %d) by %s', newManagerKey, promotion.params.managerGeneration,
1619
+ byManagerKeyPair.publicKeyB64);
1620
+ }
1621
+
1622
+ /**
1623
+ * The `Strand.Manager` insert behind {@link addManager}: reads the authorizer's generation,
1624
+ * seats the new manager one above it under a fresh stamp, and signs all three.
1625
+ */
1626
+ async function managerAddStatement(db: Database, params: AddManagerParams): Promise<StrandStatements> {
1322
1627
  const { byManagerKeyPair, newManagerKey } = params;
1323
1628
  const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
1324
1629
  // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
@@ -1328,17 +1633,21 @@ export async function addManager(db: Database, params: AddManagerParams): Promis
1328
1633
  // from outside this function, clamp or reject here rather than emitting a dead row.
1329
1634
  const generation = authorizer == null ? 1 : authorizer.generation + 1;
1330
1635
  const stampId = generateStrandStampId();
1331
- const signature = signStrandApproval(
1332
- ['Strand.Manager', 'add', newManagerKey, generation, stampId],
1333
- byManagerKeyPair.privateKeyB64,
1334
- );
1335
- await db.exec(
1336
- `insert into Strand.Manager (MemberKey, Generation, StampId)
1337
- with context ManagerKey = ?, Signature = ?
1338
- values (?, ?, ?)`,
1339
- [byManagerKeyPair.publicKeyB64, signature, newManagerKey, generation, stampId],
1340
- );
1341
- log('Added manager %s (generation %d) by %s', newManagerKey, generation, byManagerKeyPair.publicKeyB64);
1636
+ return {
1637
+ sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1638
+ with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1639
+ values (:managerMemberKey, :managerGeneration, :managerStampId);`,
1640
+ params: {
1641
+ managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
1642
+ managerSignature: signStrandApproval(
1643
+ ['Strand.Manager', 'add', newManagerKey, generation, stampId],
1644
+ byManagerKeyPair.privateKeyB64,
1645
+ ),
1646
+ managerMemberKey: newManagerKey,
1647
+ managerGeneration: generation,
1648
+ managerStampId: stampId,
1649
+ },
1650
+ };
1342
1651
  }
1343
1652
 
1344
1653
  /** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
@@ -1371,15 +1680,19 @@ export type AdmitManagerParams = AddManagerParams;
1371
1680
  *
1372
1681
  * @param db - The closed strand's database (founder manager already seated).
1373
1682
  * @param params - The authorizing manager's keypair and the key to admit + promote.
1683
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1374
1684
  * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1375
1685
  * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1376
1686
  */
1377
- export async function admitManager(db: Database, params: AdmitManagerParams): Promise<void> {
1687
+ export async function admitManager(
1688
+ db: Database,
1689
+ params: AdmitManagerParams,
1690
+ options?: StrandWriteOptions,
1691
+ ): Promise<void> {
1378
1692
  const { byManagerKeyPair, newManagerKey } = params;
1379
- await inStrandTransaction(db, async () => {
1380
- await addMemberByManager(db, { managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1381
- await addManager(db, { byManagerKeyPair, newManagerKey });
1382
- });
1693
+ const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1694
+ const promotion = await managerAddStatement(db, params);
1695
+ await execStrandTransaction(db, combineStatements(admission, promotion), options);
1383
1696
  log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1384
1697
  }
1385
1698
 
@@ -1468,13 +1781,18 @@ export interface RemoveManagerParams {
1468
1781
  *
1469
1782
  * @param db - The closed strand's database.
1470
1783
  * @param params - The authorizing keypair and the target manager key.
1784
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1471
1785
  * @throws If the signer IS the target and it is the sole manager (before writing —
1472
1786
  * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1473
1787
  * rejects (a signer that is neither another existing manager nor the target
1474
1788
  * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1475
1789
  * whole delete+tombstone transaction rolls back.
1476
1790
  */
1477
- export async function removeManager(db: Database, params: RemoveManagerParams): Promise<void> {
1791
+ export async function removeManager(
1792
+ db: Database,
1793
+ params: RemoveManagerParams,
1794
+ options?: StrandWriteOptions,
1795
+ ): Promise<void> {
1478
1796
  const { byManagerKeyPair, targetManagerKey } = params;
1479
1797
  const target = await managerRow(db, targetManagerKey);
1480
1798
  if (target == null) {
@@ -1492,18 +1810,37 @@ export async function removeManager(db: Database, params: RemoveManagerParams):
1492
1810
  ['Strand.Manager', tag, targetManagerKey, target.stampId],
1493
1811
  byManagerKeyPair.privateKeyB64,
1494
1812
  );
1495
- await inStrandTransaction(db, async () => {
1496
- await db.exec(
1497
- `delete from Strand.Manager
1498
- with context ManagerKey = ?, Signature = ?
1499
- where MemberKey = ?`,
1500
- [byManagerKeyPair.publicKeyB64, signature, targetManagerKey],
1501
- );
1502
- await insertRevocation(db, 'Manager', target.stampId, byManagerKeyPair);
1503
- });
1813
+ await execStrandTransaction(
1814
+ db,
1815
+ managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId),
1816
+ options,
1817
+ );
1504
1818
  log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1505
1819
  }
1506
1820
 
1821
+ /**
1822
+ * The `Strand.Manager` delete plus its tombstone, shared by {@link removeManager} and
1823
+ * {@link sealStrand}, which differ only in the action tag `signature` was minted under.
1824
+ */
1825
+ function managerRemovalStatements(
1826
+ signer: Ed25519KeyPair,
1827
+ signature: string,
1828
+ targetManagerKey: string,
1829
+ targetStampId: string,
1830
+ ): StrandStatements {
1831
+ const removal: StrandStatements = {
1832
+ sql: `delete from Strand.Manager
1833
+ with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1834
+ where MemberKey = :managerMemberKey;`,
1835
+ params: {
1836
+ managerRemoverKey: signer.publicKeyB64,
1837
+ managerRemoverSignature: signature,
1838
+ managerMemberKey: targetManagerKey,
1839
+ },
1840
+ };
1841
+ return combineStatements(removal, revocationStatement('Manager', targetStampId, signer));
1842
+ }
1843
+
1507
1844
  // ── Sealing (the sole manager permanently freezes admission) ──────────────────
1508
1845
 
1509
1846
  /** Parameters for {@link sealStrand}. */
@@ -1555,6 +1892,7 @@ export interface SealStrandParams {
1555
1892
  *
1556
1893
  * @param db - The closed strand's database.
1557
1894
  * @param params - The sole manager's own keypair.
1895
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1558
1896
  * @throws If more than one manager exists (the caller wants {@link removeManager}),
1559
1897
  * or the supplied keypair holds no `Manager` row while one exists, or the strand
1560
1898
  * holds no `Manager` row and has never retired one (not founded yet — see
@@ -1563,7 +1901,11 @@ export interface SealStrandParams {
1563
1901
  * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1564
1902
  * and {@link removeManager}'s absent-row no-op).
1565
1903
  */
1566
- export async function sealStrand(db: Database, params: SealStrandParams): Promise<void> {
1904
+ export async function sealStrand(
1905
+ db: Database,
1906
+ params: SealStrandParams,
1907
+ options?: StrandWriteOptions,
1908
+ ): Promise<void> {
1567
1909
  const { managerKeyPair } = params;
1568
1910
  const managerCount = await strandTableCount(db, 'Manager');
1569
1911
  if (managerCount === 0) {
@@ -1594,15 +1936,11 @@ export async function sealStrand(db: Database, params: SealStrandParams): Promis
1594
1936
  ['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
1595
1937
  managerKeyPair.privateKeyB64,
1596
1938
  );
1597
- await inStrandTransaction(db, async () => {
1598
- await db.exec(
1599
- `delete from Strand.Manager
1600
- with context ManagerKey = ?, Signature = ?
1601
- where MemberKey = ?`,
1602
- [managerKeyPair.publicKeyB64, signature, managerKeyPair.publicKeyB64],
1603
- );
1604
- await insertRevocation(db, 'Manager', self.stampId, managerKeyPair);
1605
- });
1939
+ await execStrandTransaction(
1940
+ db,
1941
+ managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId),
1942
+ options,
1943
+ );
1606
1944
  log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1607
1945
  }
1608
1946