@serfab/cadre-core 1.4.0 → 1.6.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 (63) hide show
  1. package/dist/cadre-node.d.ts +38 -15
  2. package/dist/cadre-node.js +81 -29
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-database.d.ts +24 -10
  5. package/dist/control-database.js +48 -16
  6. package/dist/control-database.js.map +1 -1
  7. package/dist/control-read-retry.d.ts +12 -0
  8. package/dist/control-read-retry.js +12 -0
  9. package/dist/control-read-retry.js.map +1 -1
  10. package/dist/control-write-retry.d.ts +5 -4
  11. package/dist/control-write-retry.js +24 -7
  12. package/dist/control-write-retry.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/link-budget.d.ts +224 -0
  17. package/dist/link-budget.js +239 -0
  18. package/dist/link-budget.js.map +1 -0
  19. package/dist/peer-dial.d.ts +36 -17
  20. package/dist/peer-dial.js +37 -17
  21. package/dist/peer-dial.js.map +1 -1
  22. package/dist/peer-join-backfill.d.ts +58 -4
  23. package/dist/peer-join-backfill.js +166 -20
  24. package/dist/peer-join-backfill.js.map +1 -1
  25. package/dist/relay-reservation.d.ts +59 -11
  26. package/dist/relay-reservation.js +169 -43
  27. package/dist/relay-reservation.js.map +1 -1
  28. package/dist/seed-bootstrap.js +2 -1
  29. package/dist/seed-bootstrap.js.map +1 -1
  30. package/dist/strand-first-sync-gate.d.ts +105 -22
  31. package/dist/strand-first-sync-gate.js +121 -27
  32. package/dist/strand-first-sync-gate.js.map +1 -1
  33. package/dist/strand-instance-manager.d.ts +27 -2
  34. package/dist/strand-instance-manager.js +60 -13
  35. package/dist/strand-instance-manager.js.map +1 -1
  36. package/dist/strand-membership-reconciler.d.ts +200 -45
  37. package/dist/strand-membership-reconciler.js +302 -86
  38. package/dist/strand-membership-reconciler.js.map +1 -1
  39. package/dist/strand-membership-writer.d.ts +3 -2
  40. package/dist/strand-membership-writer.js +67 -90
  41. package/dist/strand-membership-writer.js.map +1 -1
  42. package/dist/strand-network-config.d.ts +2 -2
  43. package/dist/strand-network-config.js +2 -2
  44. package/dist/types.d.ts +105 -9
  45. package/dist/types.js +20 -4
  46. package/dist/types.js.map +1 -1
  47. package/package.json +8 -8
  48. package/src/cadre-node.ts +7449 -7397
  49. package/src/control-database.ts +48 -16
  50. package/src/control-read-retry.ts +214 -202
  51. package/src/control-write-retry.ts +24 -7
  52. package/src/index.ts +582 -563
  53. package/src/link-budget.ts +264 -0
  54. package/src/peer-dial.ts +39 -17
  55. package/src/peer-join-backfill.ts +648 -478
  56. package/src/relay-reservation.ts +1119 -951
  57. package/src/seed-bootstrap.ts +2 -1
  58. package/src/strand-first-sync-gate.ts +352 -258
  59. package/src/strand-instance-manager.ts +1520 -1458
  60. package/src/strand-membership-reconciler.ts +334 -92
  61. package/src/strand-membership-writer.ts +1962 -1987
  62. package/src/strand-network-config.ts +2 -2
  63. package/src/types.ts +104 -8
@@ -1,1987 +1,1962 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import type { Database, SqlValue } from '@quereus/quereus';
4
- import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
5
- import type { SAppConfig } from './types.js';
6
- import type { Ed25519KeyPair } from './ed25519-key.js';
7
- import { canonicalDatetime } from './canonical-datetime.js';
8
-
9
- const log = debug('sereus:cadre:strand-membership');
10
-
11
- /**
12
- * The engine (rules-logic system) assumed to manage every strand. Persisted in
13
- * `Strand.Header.Engine`.
14
- *
15
- * PLACEHOLDER SEAM: there is no engine-selection mechanism yet — Quereus is the
16
- * only engine. When a real engine-config seam lands, the founder bootstrap should
17
- * read the strand's chosen engine from there instead of this constant. Documented
18
- * deliberately so the column is non-null and self-describing in the meantime.
19
- */
20
- export const STRAND_ENGINE = 'quereus';
21
-
22
- /**
23
- * The pinned engine version recorded in `Strand.Header.EngineVersion`. A pinned
24
- * string for the same reason as {@link STRAND_ENGINE}: it is a documented
25
- * placeholder until an engine-selection seam supplies the real running version.
26
- */
27
- export const STRAND_ENGINE_VERSION = '0.1.0';
28
-
29
- /**
30
- * The single-payload ed25519 signer the `Strand.Invite`/`Strand.ConsumedInvite`
31
- * handshake constraints verify against.
32
- *
33
- * Those constraints sign a single SHA-256 digest over a `'|'`-joined payload
34
- * (e.g. `InviteValid` verifies
35
- * `verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), ...)`).
36
- * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
37
- * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
38
- * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
39
- * The membership/RBAC tables (Member/Manager/MemberPeer/Revocation) instead use
40
- * domain/action-tagged VARIADIC digests — see {@link signStrandApproval}.
41
- *
42
- * All strand keys are ed25519, so the explicit `'ed25519'` curve arg is mandatory
43
- * (the crypto plugin otherwise defaults to secp256k1). Mirrors the proven
44
- * `signItem` helper in the `rbac-signed-write` integration scenario.
45
- *
46
- * @param payload - The `'|'`-joined payload the matching constraint hashes.
47
- * @param privateKeyB64 - The signer's base64url ed25519 private seed.
48
- * @returns The base64url ed25519 signature over the payload's SHA-256 digest.
49
- */
50
- export function signStrandPayload(payload: string, privateKeyB64: string): string {
51
- const hashBytes = digest([payload], 'sha256', 'bytes') as Uint8Array;
52
- return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
53
- }
54
-
55
- /**
56
- * The verifier counterpart to {@link signStrandPayload}.
57
- *
58
- * Mirrors what the single-payload `Strand.*` constraints compute:
59
- * `verify(digest(payload), signature, publicKey, 'ed25519')`.
60
- * SQL `digest`'s default output and `verify`'s default `inputEncoding`/`sigEncoding`/
61
- * `keyEncoding` are all base64url, so the off-engine check operates on the exact
62
- * same bytes the in-engine CHECK does. Used by the off-engine `MemberVerifier`
63
- * (pre-flight registration checks) so the on-engine constraint is not the only
64
- * place a member self-proof is validated.
65
- *
66
- * @param payload - The `'|'`-joined payload the matching constraint hashes.
67
- * @param signatureB64 - The base64url ed25519 signature to check.
68
- * @param publicKeyB64 - The base64url ed25519 public key to verify against.
69
- * @returns `true` iff the signature is valid for the payload under that key.
70
- */
71
- export function verifyStrandPayload(payload: string, signatureB64: string, publicKeyB64: string): boolean {
72
- const payloadDigest = digest([payload], 'sha256', 'base64url') as string;
73
- return verify(payloadDigest, signatureB64, publicKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
74
- }
75
-
76
- /**
77
- * Sign a domain/action-tagged `Strand.*` membership/RBAC approval.
78
- *
79
- * Every `Authorized` branch on `Member`/`Manager`/`MemberPeer`/`Revocation`
80
- * verifies a VARIADIC digest — `digest('<domain>', '<action>', <field>, ...,
81
- * <StampId>)` — whose leading two elements are fixed literals (the domain tag,
82
- * e.g. `'Strand.Member'`, and an action tag, e.g. `'add'`) and whose trailing
83
- * element is the row's per-incarnation StampId. The tags keep an approval from
84
- * verifying against any rule but the one it was minted for (the idiom of the
85
- * control layer's `buildAuthorizationMessage`); the StampId binding makes it
86
- * single-use — it authorizes exactly one incarnation of one row, and dies when
87
- * that row's stamp is retired into `Strand.Revocation`.
88
- *
89
- * The caller passes the digest elements exactly as the matching SQL constraint
90
- * lists them (each SQL argument = one array element), PRESERVING TYPE: the
91
- * crypto plugin's digest framing is type-tagged (INTEGER 1 and TEXT '1' encode
92
- * differently, deliberately), so `Manager.Authorized`'s promotion branch —
93
- * whose digest includes the INTEGER `new.Generation` — must be signed over the
94
- * NUMBER, not its string form. Integer `number` and `bigint` encode
95
- * identically in that framing, so JS-side numbers match however the engine
96
- * represents the SQL integer.
97
- *
98
- * Like {@link signStrandPayload}, the raw digest BYTES are ed25519-signed
99
- * directly, matching SQL `verify(...)`'s default base64url input decoding.
100
- *
101
- * @param fields - The digest elements, in constraint order (tags first, StampId last).
102
- * @param privateKeyB64 - The signer's base64url ed25519 private seed.
103
- * @returns The base64url ed25519 signature over the tagged digest.
104
- */
105
- export function signStrandApproval(fields: ReadonlyArray<string | number>, privateKeyB64: string): string {
106
- const hashBytes = digest(fields, 'sha256', 'bytes') as Uint8Array;
107
- return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
108
- }
109
-
110
- /**
111
- * Mint a fresh per-row authorization nonce (StampId) for a `Strand.Member`,
112
- * `Strand.Manager`, or `Strand.MemberPeer` row.
113
- *
114
- * 256 bits of CSPRNG output, base64url-encoded — enough entropy that a future
115
- * legitimate stamp cannot be guessed and pre-planted into `Strand.Revocation`
116
- * (the `RowIsGone` comment in `schemas/strand.qsql` leans on exactly this).
117
- * Plain randomness, deliberately NOT the control layer's peer-derived
118
- * `generateStampId`: strand rows are not per-peer, so entropy alone carries
119
- * the uniqueness.
120
- *
121
- * @returns A fresh base64url stamp (43 chars for 32 bytes).
122
- */
123
- export function generateStrandStampId(): string {
124
- // randomBytes' first arg is BITS, not bytes — 256 bits = 32 bytes.
125
- const bytes = randomBytes(256, 'bytes') as Uint8Array;
126
- return uint8ArrayToString(bytes, 'base64url');
127
- }
128
-
129
- // ── Write batches (every membership write is one indivisible transaction) ──────
130
-
131
- /**
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".
136
- */
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';
145
- }
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> {
241
- try {
242
- await db.exec(`begin transaction;\n${statements.sql}\ncommit;`, statements.params);
243
- return;
244
- } catch (error) {
245
- if (!isNestedBeginRefusal(error)) {
246
- await rollbackBatchLeftOpen(db, error);
247
- throw error;
248
- }
249
- if (options?.joinOpenTransaction === false) {
250
- throw new StrandTransactionBusyError({ cause: error });
251
- }
252
- }
253
- await db.exec(statements.sql, statements.params);
254
- }
255
-
256
- /**
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.
275
- *
276
- * Always batched with the delete it accompanies — the guarded tables'
277
- * `RevocationRecorded` (deferred) requires the tombstone at commit, and
278
- * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
279
- * the pair only ever lands together.
280
- *
281
- * `Revocation.Authorized` verifies the retire-tagged digest against a
282
- * PRE-transaction (`committed.Member`) row, so the retiree must have been a
283
- * member BEFORE this transaction. Every writer flow satisfies this: managers are
284
- * members, and a self-departing member is committed until this very transaction
285
- * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
286
- * context is non-null — both fields are always supplied.
287
- *
288
- * @param tableName - Which guarded table the stamp belonged to.
289
- * @param stampId - The retired stamp.
290
- * @param retiree - The committed member filing the tombstone (the delete's own signer).
291
- */
292
- function revocationStatement(
293
- tableName: 'Member' | 'Manager' | 'MemberPeer',
294
- stampId: string,
295
- retiree: Ed25519KeyPair,
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
- };
308
- }
309
-
310
- /** Parameters for {@link bootstrapFounderMembership}. */
311
- export interface FounderBootstrapParams {
312
- /** The strand id — written to `Header.Id`. */
313
- strandId: string;
314
- /** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+Manager). */
315
- type: 'o' | 'c';
316
- /** The sApp config whose id/version/schema/signature populate the Header. */
317
- sApp: SAppConfig;
318
- /**
319
- * The founder's derived strand keypair (from {@link strandMemberKeyPair}). Its
320
- * `publicKeyB64` becomes the founding `Member.Key` and `Manager.MemberKey`.
321
- * Required for a closed strand; ignored for an open strand.
322
- */
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
- }
356
- }
357
-
358
- /**
359
- * Count rows in a `Strand.*` table as seen by this database instance.
360
- *
361
- * The table name is a fixed literal supplied by this module (never user input),
362
- * so the interpolation is not an injection surface — it just keeps the singleton
363
- * insert-if-absent guards terse.
364
- */
365
- async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Manager'): Promise<number> {
366
- for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
367
- return (row.Count as number) ?? 0;
368
- }
369
- return 0;
370
- }
371
-
372
- /**
373
- * Whether any `Strand.Revocation` row has ever retired a `Manager` seat — the
374
- * exact condition that permanently closes the founding branch of
375
- * `Manager.Authorized` (see the schema's bootstrap-branch comment). Unfiltered
376
- * scan + JavaScript comparison, the module's scan-not-seek idiom (rationale on
377
- * {@link scanMemberPeers}).
378
- *
379
- * NOTE: walks the whole append-only `Strand.Revocation` table when no `Manager`
380
- * tombstone is present (it returns on the first one it finds). Cheap at strand
381
- * scale; if membership churn ever makes that table large — the growth the
382
- * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
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`).
391
- */
392
- async function strandHasManagerRevocation(db: Database): Promise<boolean> {
393
- for await (const row of db.eval('select TableName from Strand.Revocation')) {
394
- if (row.TableName === 'Manager') {
395
- return true;
396
- }
397
- }
398
- return false;
399
- }
400
-
401
- /**
402
- * Insert the singleton `Strand.Header` if absent.
403
- *
404
- * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
405
- * NULL; the table declares no `null` columns), so all eight are supplied non-null.
406
- * A missing `sApp.signature` (dev `requireSignedSchemas:false`) coalesces to `''`
407
- * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
408
- * so the insert needs no `with context`.
409
- */
410
- async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
411
- if (await strandTableCount(db, 'Header') > 0) {
412
- log('Header already present for strand %s; skipping', params.strandId);
413
- return;
414
- }
415
- await db.exec(
416
- `insert into Strand.Header
417
- (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
418
- values (?, ?, ?, ?, ?, ?, ?, ?)`,
419
- [
420
- params.strandId,
421
- params.type,
422
- params.sApp.id,
423
- params.sApp.version,
424
- params.sApp.schema,
425
- params.sApp.signature ?? '',
426
- STRAND_ENGINE,
427
- STRAND_ENGINE_VERSION,
428
- ],
429
- );
430
- log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
431
- }
432
-
433
- /**
434
- * Insert the founding `Strand.Member` if no member exists yet.
435
- *
436
- * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
437
- * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
438
- * needed — the context fields are explicit nulls. The branch also caps the
439
- * POST-image at one member, so it waives authorization for exactly this single
440
- * seat, never for a batch. The row still mints a fresh StampId — the column is
441
- * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
442
- * count makes this idempotent: a re-run (the founder re-`addStrand`/
443
- * `resumeStrand`) finds the member present and skips.
444
- */
445
- async function insertFounderMemberIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
446
- if (await strandTableCount(db, 'Member') > 0) {
447
- log('Member already present for strand %s; skipping founder Member', strandId);
448
- return;
449
- }
450
- await db.exec(
451
- `insert into Strand.Member (Key, StampId)
452
- with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
453
- values (?, ?)`,
454
- [memberKey, generateStrandStampId()],
455
- );
456
- log('Inserted founding Member for strand %s', strandId);
457
- }
458
-
459
- /**
460
- * Insert the founding `Strand.Manager` if no manager exists yet.
461
- *
462
- * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
463
- * no signature is needed (a fresh StampId is still minted — the column is NOT
464
- * NULL). The founder is seated at `Generation = 0` — the root of
465
- * the manager lineage; the bootstrap branch requires exactly that value, and every
466
- * later manager is seated at a strictly greater generation (see {@link addManager}).
467
- * Idempotent via the same count guard.
468
- *
469
- * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
470
- * the founding state and now reads `exists (select 1 from Member M where M.Key =
471
- * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
472
- * founding `Member` row MUST already exist when this commits — a Manager-first
473
- * seeding path is rejected. It must also run AFTER the Header insert:
474
- * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
475
- * sequential auto-commit Header→Member→Manager order in
476
- * {@link bootstrapFounderMembership} satisfies both.
477
- *
478
- * SEALED strands are skipped, not re-founded: a strand whose sole manager has
479
- * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
480
- * closed for good (the schema's bootstrap branch requires no `Manager`-table
481
- * `Revocation` row to exist). Attempting the founding insert here would be
482
- * schema-rejected and turn every founder restart of a sealed strand into a
483
- * loud failure — so this mirrors the schema's own gate and returns quietly.
484
- * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
485
- * rows at all, so that restart still completes the founding.
486
- */
487
- async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
488
- if (await strandTableCount(db, 'Manager') > 0) {
489
- log('Manager already present for strand %s; skipping founder Manager', strandId);
490
- return;
491
- }
492
- if (await strandHasManagerRevocation(db)) {
493
- log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
494
- return;
495
- }
496
- await db.exec(
497
- `insert into Strand.Manager (MemberKey, Generation, StampId)
498
- with context ManagerKey = null, Signature = null
499
- values (?, 0, ?)`,
500
- [memberKey, generateStrandStampId()],
501
- );
502
- log('Inserted founding Manager for strand %s', strandId);
503
- }
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
-
525
- /**
526
- * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
527
- *
528
- * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
529
- * published the strand). A joiner never calls this — it receives these rows via
530
- * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
531
- * restart / founder re-`addStrand` is a no-op and never double-inserts.
532
- *
533
- * Behavior by strand type:
534
- * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
535
- * `OnlyClosed` and would trip that constraint — they are skipped entirely.
536
- * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
537
- * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
538
- * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
539
- * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
540
- * evaluate at commit.
541
- *
542
- * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
543
- * Manager could never admit anyone, so failing loudly here (which propagates out
544
- * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
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
- *
551
- * @param db - The strand's Quereus database (schema already applied).
552
- * @param params - Strand id/type, the sApp config for the Header, and (closed only)
553
- * the derived founder keypair and the shared-derived public key to refuse.
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`.
557
- */
558
- export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
559
- const { strandId, type, founderKeyPair, sharedMemberPublicKey } = params;
560
- log('Founder bootstrap for strand %s (type %s)', strandId, type);
561
-
562
- // Validate BEFORE writing anything: a closed strand with no founder key must
563
- // fail without leaving a closed `Header` that has no founding Member/Manager
564
- // (such a strand could never admit anyone).
565
- if (type === 'c' && !founderKeyPair) {
566
- throw new Error(
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.',
569
- );
570
- }
571
- if (type === 'c' && sharedMemberPublicKey !== undefined) {
572
- await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
573
- }
574
-
575
- // Header is written for every strand; Member/Manager are closed-only.
576
- await insertHeaderIfAbsent(db, params);
577
-
578
- if (type === 'o') {
579
- // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
580
- return;
581
- }
582
-
583
- const memberKey = founderKeyPair!.publicKeyB64;
584
- await insertFounderMemberIfAbsent(db, memberKey, strandId);
585
- await insertFounderManagerIfAbsent(db, memberKey, strandId);
586
- log('Founder bootstrap complete for closed strand %s', strandId);
587
- }
588
-
589
- // ── Invite issuance + consumption (the per-strand join handshake) ─────────────
590
-
591
- /** Parameters for {@link issueInvite}. */
592
- export interface IssueInviteParams {
593
- /**
594
- * The issuing manager's strand keypair. Its `publicKeyB64` must already be a
595
- * `Strand.Manager` row (the `InviteValid` constraint rejects a non-manager).
596
- */
597
- managerKeyPair: Ed25519KeyPair;
598
- /**
599
- * Optional invite expiry as epoch milliseconds. When set, it is canonicalised
600
- * via {@link canonicalDatetime} so the signed payload segment byte-matches the
601
- * `datetime`-coerced `Invite.Expiration` the deferred CHECK sees. When omitted,
602
- * the invite never expires and the signed segment is `''` (matching the schema's
603
- * `coalesce(new.Expiration, '')`).
604
- */
605
- expiration?: number;
606
- }
607
-
608
- /** The minted invite: a public key (the `Invite.Key`) plus its private seed. */
609
- export interface IssuedInvite {
610
- /** The invite ed25519 PUBLIC key (base64url) — the `Invite.Key` primary key. */
611
- inviteKey: string;
612
- /**
613
- * The invite ed25519 PRIVATE seed (base64url). Handed out-of-band to the
614
- * invitee; whoever holds it can {@link consumeInvite} exactly once. NEVER
615
- * persisted in the strand — only the public key is.
616
- */
617
- invitePrivateKey: string;
618
- }
619
-
620
- /**
621
- * Issue a single-use invitation to join a closed strand.
622
- *
623
- * Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
624
- * builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
625
- * signs it TWICE: with the manager private key (→ `ManagerSignature`, proving
626
- * a manager issued it) and with the invite private key (→ `InviteSignature`,
627
- * proving the issuer actually holds the invite secret). Both signatures plus the
628
- * manager public key are bound as constraint context for the `Invite` insert.
629
- *
630
- * The returned `invitePrivateKey` is the only secret the invitee needs to redeem
631
- * the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
632
- * is). Single-use is enforced at consumption time: `ConsumedInvite`'s primary key
633
- * is `InviteKey`, so a given invite can be consumed at most once.
634
- *
635
- * @param db - The closed strand's database (founder already bootstrapped).
636
- * @param params - The issuing manager keypair and optional expiry.
637
- * @returns The invite public key and the out-of-band private seed.
638
- */
639
- export async function issueInvite(db: Database, params: IssueInviteParams): Promise<IssuedInvite> {
640
- const { managerKeyPair, expiration } = params;
641
-
642
- const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
643
- const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url') as string;
644
-
645
- // Canonicalise the expiry to the engine's stored `datetime` form so the signed
646
- // segment matches `new.Expiration` post-coercion; null expiry signs as ''.
647
- const expirationSegment = expiration == null ? '' : await canonicalDatetime(db, expiration);
648
- const expirationColumn = expiration == null ? null : expirationSegment;
649
-
650
- const payload = `${inviteKey}|${expirationSegment}`;
651
- const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
652
- const inviteSignature = signStrandPayload(payload, invitePrivateKey);
653
-
654
- await db.exec(
655
- `insert into Strand.Invite (Key, Expiration)
656
- with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
657
- values (?, ?)`,
658
- [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
659
- );
660
- log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
661
-
662
- return { inviteKey, invitePrivateKey };
663
- }
664
-
665
- /** Parameters for {@link consumeInvite}. */
666
- export interface ConsumeInviteParams {
667
- /** The `Invite.Key` (invite PUBLIC key, base64url) being redeemed. */
668
- inviteKey: string;
669
- /** The matching invite PRIVATE seed (base64url) received out-of-band. */
670
- invitePrivateKey: string;
671
- /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
672
- memberKey: string;
673
- /**
674
- * The instant (epoch ms) to compare against the invite's `Expiration` for the
675
- * `NotExpired` gate. Defaults to `Date.now()`; tests pin it so the comparison is
676
- * deterministic. Mirrors the control-layer `redeemInvitation`'s `nowMs` convention.
677
- */
678
- nowMs?: number;
679
- }
680
-
681
- /**
682
- * Redeem an invite to admit a new `Member`, atomically.
683
- *
684
- * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
685
- * `ConsumedInvite`'s `MemberExists` needs the `Member` row — a circular
686
- * dependency. Both are deferred (subquery-bearing) checks that evaluate
687
- * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
688
- * transaction lets both rows exist when the deferred checks fire. This mirrors
689
- * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
690
- *
691
- * The `ConsumedInvite` insert carries an `InviteSignature` over
692
- * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
693
- * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
694
- * signature — its admission is the existence of the matching `ConsumedInvite` —
695
- * but still mints a fresh StampId (NOT NULL on every path; a revoked-then-
696
- * re-invited member is a fresh incarnation under a fresh stamp).
697
- *
698
- * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
699
- * of the same invite is rejected by the PK (a distinct layer from the control
700
- * network's `FormationUsage` single-use, which gates strand FORMATION, not the
701
- * per-strand member join enforced here).
702
- *
703
- * Expiry: the `ConsumedInvite.NotExpired` deferred check rejects redeeming an invite
704
- * whose `Expiration` is at or before `context.Now`. `Now` is supplied here as a
705
- * canonical-datetime string from `canonicalDatetime(db, nowMs)` — the SAME transform
706
- * `issueInvite` uses to store `Invite.Expiration` — so both sides of the schema's
707
- * `I.Expiration > context.Now` comparison are byte-identical canonical strings and
708
- * the lexical `>` orders chronologically at any granularity. (This intentionally
709
- * diverges from the control layer, which passes `Now` as a JS ISO string; Quereus
710
- * does not coerce context params, so an ISO `Now` would be compared lexically against
711
- * the canonical, T-separated `Expiration` and could mis-order near-same-instant
712
- * timestamps (due to a trailing `.000Z` suffix). The control tests only use far-future/far-past expiries, so that latent
713
- * skew never bites there — the strand layer avoids it outright.) A null `Expiration`
714
- * never expires. Like `ValidUsage`, `NotExpired` defers to commit, so an expired
715
- * invite rolls back the whole txn — neither the `Member` nor the `ConsumedInvite`
716
- * row survives.
717
- *
718
- * @param db - The closed strand's database.
719
- * @param params - The invite key/secret, the joining member's public key, and an
720
- * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
721
- * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
722
- * @throws If any constraint rejects (bad signature, missing invite, or an expired
723
- * invite); the whole transaction rolls back (neither the `Member` nor the
724
- * `ConsumedInvite` row survives).
725
- * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
726
- * transaction is open; nothing was written.
727
- */
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
- }
745
-
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
- }
771
-
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);
805
- }
806
-
807
- /**
808
- * Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
809
- *
810
- * Unfiltered scan + JavaScript comparison, deliberately: `ConsumedInvite` and
811
- * `CancelledInvite` each have the single column `InviteKey` as their primary key, so
812
- * ANY where-equality on it is a FULL-PK predicate — which the optimystic virtual-table
813
- * module serves via a single-key point lookup that can MISS on a networked strand (the
814
- * argument {@link scanMemberPeers} makes in full). Reading the whole column and
815
- * comparing in JS depends only on the scan returning a SUPERSET of the live rows.
816
- *
817
- * The table name is a fixed literal supplied by this module (never user input), so the
818
- * interpolation is not an injection surface — same as {@link strandTableCount}.
819
- *
820
- * NOTE: every caller pays a WHOLE-column read — {@link cancelInvite}'s idempotence guard
821
- * reads all cancelled keys to answer one membership question, and
822
- * {@link listOutstandingInvites} reads both marker tables per call. Fine at strand scale
823
- * (a handful of invitations); if invite churn ever gets large this wants a real filtered
824
- * read, which in turn needs the networked point-lookup gap closed
825
- * (`debt-composite-pk-point-lookup-unreliable-untracked`).
826
- */
827
- async function scanInviteKeys(db: Database, table: 'ConsumedInvite' | 'CancelledInvite'): Promise<Set<string>> {
828
- const keys = new Set<string>();
829
- for await (const row of db.eval(`select InviteKey from Strand.${table}`)) {
830
- keys.add(row.InviteKey as string);
831
- }
832
- return keys;
833
- }
834
-
835
- /** Parameters for {@link cancelInvite}. */
836
- export interface CancelInviteParams {
837
- /**
838
- * The cancelling manager's strand keypair. Its `publicKeyB64` must be a
839
- * PRE-EXISTING `Strand.Manager` row — `CancelledInvite.Authorized` reads
840
- * `committed.Manager`, so a manager seated in the SAME transaction cannot cancel.
841
- */
842
- managerKeyPair: Ed25519KeyPair;
843
- /** The `Invite.Key` to kill. */
844
- inviteKey: string;
845
- }
846
-
847
- /**
848
- * Cancel an outstanding invitation, permanently: it may never be consumed.
849
- *
850
- * Files a `Strand.CancelledInvite` tombstone carrying a manager signature over
851
- * `digest('Strand.CancelledInvite', 'cancel', InviteKey)`. The digest binds the exact
852
- * invite key, so an approval minted for one invitation cannot cancel another.
853
- *
854
- * One row, one statement — no transaction needed. `CancelledInvite.Authorized` is a
855
- * deferred (subquery-bearing) check, so it fires at this statement's own auto-commit.
856
- *
857
- * Insert-if-absent, matching {@link registerMemberPeer}'s and {@link revokeMember}'s
858
- * restart-safe shape: a repeat cancellation logs and returns rather than throwing on the
859
- * primary key. (Check-then-write is not atomic; the primary key is the real backstop —
860
- * the guard is for the sequential repeat/restart path.)
861
- *
862
- * Cancellation is what makes removal a re-entry gate: it is the `ConsumedInvite` insert
863
- * that `NotCancelled` blocks, and `Member.Authorized`'s invite branch needs a
864
- * same-transaction FRESH `ConsumedInvite` row, so a cancelled invitation rolls the whole
865
- * join back. Un-cancelling is impossible (`CancelledInvite.Immutable`) — to re-invite a
866
- * party, {@link issueInvite} a fresh invitation.
867
- *
868
- * Enumerate what there is to cancel with {@link listOutstandingInvites}: an invitation
869
- * names no invitee, so the strand cannot tell a manager WHICH invitations a departing
870
- * member holds. Invitee binding is tracked as `feat-strand-invitee-bound-invites`.
871
- *
872
- * @param db - The closed strand's database.
873
- * @param params - The cancelling manager's keypair and the invite key to kill.
874
- * @throws If `CancelledInvite.Authorized` rejects (a non-manager, a manager seated in
875
- * this transaction, or an approval minted for a different invite key) or `OnlyClosed`
876
- * rejects (an open strand); the insert rolls back, leaving no tombstone.
877
- */
878
- export async function cancelInvite(db: Database, params: CancelInviteParams): Promise<void> {
879
- const { managerKeyPair, inviteKey } = params;
880
-
881
- if ((await scanInviteKeys(db, 'CancelledInvite')).has(inviteKey)) {
882
- log('Invite %s already cancelled; skipping', inviteKey);
883
- return;
884
- }
885
-
886
- const signature = signStrandApproval(
887
- ['Strand.CancelledInvite', 'cancel', inviteKey],
888
- managerKeyPair.privateKeyB64,
889
- );
890
- await db.exec(
891
- `insert into Strand.CancelledInvite (InviteKey)
892
- with context ManagerKey = ?, ManagerSignature = ?
893
- values (?)`,
894
- [managerKeyPair.publicKeyB64, signature, inviteKey],
895
- );
896
- log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
897
- }
898
-
899
- /** One redeemable invitation, as reported by {@link listOutstandingInvites}. */
900
- export interface OutstandingInvite {
901
- /** The `Invite.Key` (invite public key, base64url). */
902
- inviteKey: string;
903
- /**
904
- * The expiry as the engine's canonical `datetime` string
905
- * (`YYYY-MM-DDTHH:MM:SS[.frac]`, no zone suffix — see {@link canonicalDatetime}), or
906
- * `null` for never-expires.
907
- *
908
- * Deliberately the STORED form, not the epoch-ms {@link IssueInviteParams.expiration}
909
- * takes: it is what compares like-for-like against another canonicalised instant, and
910
- * what the on-engine `NotExpired` gate sees. It is NOT a JS-parseable ISO instant —
911
- * pass it through the engine (or `canonicalDatetime`) rather than `Date.parse`.
912
- */
913
- expiration: string | null;
914
- }
915
-
916
- /**
917
- * The invitations that are still redeemable: neither consumed, nor cancelled, nor
918
- * expired at `nowMs`.
919
- *
920
- * `Strand.Invite` is insert-only and carries no state column, so "outstanding" is not a
921
- * property of the row — it is the `Invite` set minus the `ConsumedInvite` keys, minus
922
- * the `CancelledInvite` keys, minus anything whose `Expiration` has passed. All three
923
- * exclusions mirror gates a consume would hit anyway (`ConsumedInvite`'s primary key,
924
- * `NotCancelled`, `NotExpired`).
925
- *
926
- * Reads via unfiltered scans and compares in JavaScript — see {@link scanInviteKeys} for
927
- * why a full-PK where-equality is not reliable on a networked strand.
928
- *
929
- * Expiry is compared as `expiration > canonicalDatetime(nowMs)`: the same transform
930
- * {@link issueInvite} uses to STORE `Invite.Expiration`, so the string comparison orders
931
- * chronologically, and the same strict `>` the on-engine `NotExpired` gate uses (expiry
932
- * is exclusive — an invitation is dead at its expiry instant, not after it).
933
- *
934
- * This is the manager's enumeration side of {@link cancelInvite}, and the "door is open"
935
- * pre-flight behind `StrandMemberVerifier.isAuthorizedToJoin`.
936
- *
937
- * @param db - The closed strand's database.
938
- * @param nowMs - The instant (epoch ms) to compare expiries against; defaults to
939
- * `Date.now()`. Tests pin it so the comparison is deterministic, mirroring
940
- * {@link consumeInvite}'s `nowMs` convention.
941
- * @returns The redeemable invitations, in NO guaranteed order (the scans promise only a
942
- * superset of the live rows, not a stable sequence — sort by `expiration` if order
943
- * matters); empty if none.
944
- */
945
- export async function listOutstandingInvites(db: Database, nowMs?: number): Promise<OutstandingInvite[]> {
946
- // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
947
- const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
948
- const consumed = await scanInviteKeys(db, 'ConsumedInvite');
949
- const cancelled = await scanInviteKeys(db, 'CancelledInvite');
950
-
951
- const outstanding: OutstandingInvite[] = [];
952
- for await (const row of db.eval('select Key, Expiration from Strand.Invite')) {
953
- const inviteKey = row.Key as string;
954
- const expiration = (row.Expiration as string | null) ?? null;
955
- if (consumed.has(inviteKey) || cancelled.has(inviteKey)) {
956
- continue;
957
- }
958
- if (expiration != null && expiration <= nowCanonical) {
959
- continue;
960
- }
961
- outstanding.push({ inviteKey, expiration });
962
- }
963
- return outstanding;
964
- }
965
-
966
- // ── Member admission + removal (manager-admit, revoke, leave) ─────────────────
967
-
968
- /**
969
- * The live StampId of the `Strand.Member` row keyed by `memberKey`, or
970
- * `undefined` if no such row is visible.
971
- *
972
- * `Member`'s primary key is the single `Key` column, so ANY where-equality on it
973
- * is a full-PK predicate — which the optimystic virtual-table module serves via a
974
- * single-key point lookup that can MISS on a networked strand (the same failure
975
- * mode {@link scanMemberPeers} documents). So this reads via an UNFILTERED scan
976
- * and compares the key in JavaScript: correctness depends only on the scan
977
- * returning a superset of the live rows — the weakest possible assumption about
978
- * the storage layer.
979
- */
980
- async function memberStampId(db: Database, memberKey: string): Promise<string | undefined> {
981
- for await (const row of db.eval('select Key, StampId from Strand.Member')) {
982
- if (row.Key === memberKey) {
983
- return row.StampId as string;
984
- }
985
- }
986
- return undefined;
987
- }
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
-
1001
- /** Parameters for {@link addMemberByManager}. */
1002
- export interface AddMemberByManagerParams {
1003
- /**
1004
- * The admitting manager's strand keypair. Its `publicKeyB64` must be a
1005
- * `Strand.Manager` row; it signs the add-tagged digest over the new member key
1006
- * and its fresh stamp.
1007
- */
1008
- managerKeyPair: Ed25519KeyPair;
1009
- /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
1010
- memberKey: string;
1011
- }
1012
-
1013
- /**
1014
- * Admit a `Member` directly by manager signature — the sibling of the invite
1015
- * path on `Member.Authorized`'s direct-manager branch.
1016
- *
1017
- * The constraint verifies the add-tagged digest
1018
- * `digest('Strand.Member', 'add', new.Key, new.StampId)` against a pre-existing
1019
- * (`committed.Manager`) row matching `context.ManagerKey`. The stamp is minted
1020
- * fresh here and bound inside the signed digest, so the approval seats exactly
1021
- * this incarnation: once the member is removed (retiring the stamp), the captured
1022
- * approval can never re-seat it. No `ConsumedInvite` is involved: this is the
1023
- * path a manager uses to seat a member it already trusts (e.g. a manager-side
1024
- * enrollment that admits a party already authorised out-of-band).
1025
- *
1026
- * @param db - The closed strand's database.
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}).
1029
- * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
1030
- * rolls back, leaving no `Member` row.
1031
- */
1032
- export async function addMemberByManager(
1033
- db: Database,
1034
- params: AddMemberByManagerParams,
1035
- options?: StrandWriteOptions,
1036
- ): Promise<void> {
1037
- const { managerKeyPair, memberKey } = params;
1038
- await execStrandTransaction(db, memberAddByManagerStatement(params), options);
1039
- log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
1040
- }
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
-
1059
- /** Parameters for {@link revokeMember}. */
1060
- export interface RevokeMemberParams {
1061
- /**
1062
- * The revoking manager's strand keypair. Its `publicKeyB64` must be a
1063
- * PRE-EXISTING `Strand.Manager` row (the manager-removal branch of
1064
- * `Member.Authorized` reads `committed.Manager` — a manager seated in the same
1065
- * transaction cannot authorize); it signs the remove-tagged digest over the
1066
- * target row's key and live stamp, and files the accompanying tombstone.
1067
- */
1068
- managerKeyPair: Ed25519KeyPair;
1069
- /** The `Member.Key` row to delete. */
1070
- memberKey: string;
1071
- }
1072
-
1073
- /**
1074
- * Revoke a `Member` on the signature of an existing manager.
1075
- *
1076
- * Reads the target row's live StampId first (a quiet no-op if the row is already
1077
- * absent — a repeated or restarted revocation should not throw), then runs ONE
1078
- * transaction pairing the delete with its `Strand.Revocation` tombstone: the
1079
- * schema's `Member.RevocationRecorded` requires the tombstone in the same
1080
- * transaction, and the tombstone is what permanently retires the stamp so the
1081
- * captured admission approval can never re-seat this incarnation.
1082
- *
1083
- * The manager-removal branch of `Member.Authorized` verifies the remove-tagged
1084
- * digest `digest('Strand.Member', 'remove', old.Key, old.StampId)` against a
1085
- * `committed.Manager` row matching `context.ManagerKey` — distinct from the
1086
- * add-tagged admission payload, and bound to the live stamp, so neither a
1087
- * captured admission nor a captured PRIOR removal (whose stamp differs) replays.
1088
- *
1089
- * A revoked member cannot re-admit itself off the invite it ALREADY SPENT: that
1090
- * `ConsumedInvite` row is stale, and the invite branch requires a
1091
- * same-transaction FRESH consumption. Re-admission normally takes a fresh
1092
- * manager action ({@link addMemberByManager} or a new invite).
1093
- *
1094
- * An UNSPENT invite the revoked party holds is killed EXPLICITLY, by the manager
1095
- * calling {@link cancelInvite} on it (enumerate the candidates with
1096
- * {@link listOutstandingInvites}); a cancelled invitation can never be consumed,
1097
- * and cancellation is permanent. This removal does NOT cascade into cancellation:
1098
- * an invitation names no invitee, so there is nothing to match the removed member
1099
- * against — the strand cannot tell which invitations were meant for it, or whether
1100
- * it holds any. Binding an invitation to its invitee is tracked as
1101
- * `feat-strand-invitee-bound-invites`.
1102
- *
1103
- * A manager must resign its `Manager` row before (or in the same transaction
1104
- * as) losing membership — `Member.NotAManager` rejects un-membering a key that
1105
- * still holds a Manager row. And the strand never drops to zero members
1106
- * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
1107
- * schema NOTE states).
1108
- *
1109
- * Callers running inside a cadre runtime should follow this write with
1110
- * `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
1111
- * machines are refused at the network layer by the strand's revoked-peer gate
1112
- * (`strand-revocation-enforcer.ts`), which polls for membership changes because
1113
- * this bare `Database` handle raises no event the runtime can hook — so without
1114
- * that call the cut lands up to one poll interval (default 30 s) later.
1115
- *
1116
- * @param db - The closed strand's database.
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}).
1119
- * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
1120
- * signer), `Member.NotAManager` rejects (the target still holds a Manager
1121
- * row), or `Member.MinOneMember` rejects (the removal would empty the member
1122
- * set); the whole delete+tombstone transaction rolls back.
1123
- */
1124
- export async function revokeMember(
1125
- db: Database,
1126
- params: RevokeMemberParams,
1127
- options?: StrandWriteOptions,
1128
- ): Promise<void> {
1129
- const { managerKeyPair, memberKey } = params;
1130
- const stampId = await memberStampId(db, memberKey);
1131
- if (stampId == null) {
1132
- log('Member %s already absent; skipping revoke', memberKey);
1133
- return;
1134
- }
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
- );
1150
- log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
1151
- }
1152
-
1153
- /** Parameters for {@link leaveStrand}. */
1154
- export interface LeaveStrandParams {
1155
- /**
1156
- * The departing member's OWN strand keypair. Its `publicKeyB64` is the
1157
- * `Member.Key` row being deleted; it self-signs the leave-tagged digest —
1158
- * the self-departure branch verifies the signature against `old.Key` itself,
1159
- * so only the departing key's holder can produce it — and files the
1160
- * accompanying tombstone (it is still a committed member until this very
1161
- * transaction removes it, which is what `Revocation.Authorized` checks). No
1162
- * manager is involved.
1163
- */
1164
- memberKeyPair: Ed25519KeyPair;
1165
- }
1166
-
1167
- /**
1168
- * A member leaves the strand by deleting its own `Member` row.
1169
- *
1170
- * The self-departure branch of `Member.Authorized` verifies
1171
- * `digest('Strand.Member', 'leave', old.Key, old.StampId)` against `old.Key`
1172
- * itself via `context.MemberSignature`. The `'leave'` tag (vs the manager
1173
- * branch's `'remove'`) is belt-and-braces only — the two branches verify against
1174
- * different keys; the anti-replay fix is the stamp binding. Like
1175
- * {@link revokeMember}, the live stamp is read first (absent row → quiet no-op)
1176
- * and the delete pairs with its `Strand.Revocation` tombstone in one transaction.
1177
- *
1178
- * The same floors as {@link revokeMember} apply: a manager must resign first
1179
- * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
1180
- *
1181
- * Like {@link revokeMember}, a caller inside a cadre runtime should follow this
1182
- * with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
1183
- * makes the departing node notice its own departure promptly (the gate emits
1184
- * `strand:revoked` when it finds this node's own peer id revoked).
1185
- *
1186
- * @param db - The closed strand's database.
1187
- * @param params - The departing member's own keypair.
1188
- * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1189
- * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
1190
- * rejects; the whole delete+tombstone transaction rolls back.
1191
- */
1192
- export async function leaveStrand(
1193
- db: Database,
1194
- params: LeaveStrandParams,
1195
- options?: StrandWriteOptions,
1196
- ): Promise<void> {
1197
- const { memberKeyPair } = params;
1198
- const memberKey = memberKeyPair.publicKeyB64;
1199
- const stampId = await memberStampId(db, memberKey);
1200
- if (stampId == null) {
1201
- log('Member %s already absent; skipping leave', memberKey);
1202
- return;
1203
- }
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
- );
1218
- log('Member %s left the strand', memberKey);
1219
- }
1220
-
1221
- // ── MemberPeer registration (a member binds its own network nodes) ─────────────
1222
-
1223
- /** Parameters for {@link registerMemberPeer}. */
1224
- export interface RegisterMemberPeerParams {
1225
- /**
1226
- * The member's OWN strand keypair (`{ privateKeyB64, publicKeyB64 }`). Its
1227
- * `publicKeyB64` becomes `MemberPeer.MemberKey` and it self-signs the binding —
1228
- * `MemberPeer.Authorized` verifies the signature against `MemberKey` itself, so a
1229
- * peer can only be registered by the very member it belongs to (no manager
1230
- * involved). The founder passes its `strandMemberKeyPair`; an invited member
1231
- * passes its own keypair. Typed as {@link Ed25519KeyPair} only for the shared
1232
- * base64url keypair shape — no manager privilege is implied.
1233
- */
1234
- memberKeyPair: Ed25519KeyPair;
1235
- /** The peer/node id (libp2p peer id string) to associate with the member. */
1236
- peerId: string;
1237
- }
1238
-
1239
- /**
1240
- * Register a network node (`PeerId`) as acting on behalf of a member.
1241
- *
1242
- * The member self-signs the add-tagged digest
1243
- * `digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId)`
1244
- * with its OWN key; the schema's `MemberPeer.Authorized` verifies that signature
1245
- * against `new.MemberKey` — i.e. the member key itself — so only the member that
1246
- * owns the key can register peers for it. The stamp is minted fresh here and
1247
- * bound inside the digest, so a captured registration approval neither
1248
- * re-registers a cleared binding (its stamp is retired) nor authorizes a removal
1249
- * (wrong tag). A deferred `MemberExists` additionally requires the `Member` row
1250
- * to already exist, so a peer for a non-member is rejected at commit.
1251
- *
1252
- * Insert-if-absent: a re-register on restart (or a redundant call) is a no-op. The
1253
- * platform DOES reject a duplicate-PK insert, but a restart-safe re-register should
1254
- * succeed quietly rather than throw, so the writer guards on existence instead of
1255
- * catching. The guard ({@link memberPeerStampId}) scans the member's peers and compares
1256
- * both key columns in JavaScript rather than seeking the composite primary key — see
1257
- * {@link scanMemberPeers} for why a full-PK point lookup is not reliable on a networked
1258
- * strand. A member may register multiple DISTINCT `PeerId`s (multi-device); each is its
1259
- * own row (and its own stamp) under the same `MemberKey`.
1260
- *
1261
- * Peer DELETION is signature-checked too, not rejected outright: `Authorized` carries
1262
- * distinct delete branches (self + manager), each over its own action-tagged digest
1263
- * bound to the row's stamp. Both removal paths live in {@link removeMemberPeer}.
1264
- * UPDATE is refused outright by `NoUpdate`: a re-binding is a remove plus a fresh
1265
- * register (under a fresh stamp).
1266
- *
1267
- * @param db - The closed strand's database (the member already exists).
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}).
1270
- * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
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.
1274
- */
1275
- export async function registerMemberPeer(
1276
- db: Database,
1277
- params: RegisterMemberPeerParams,
1278
- options?: StrandWriteOptions,
1279
- ): Promise<void> {
1280
- const { memberKeyPair, peerId } = params;
1281
- const memberKey = memberKeyPair.publicKeyB64;
1282
-
1283
- if (await memberPeerStampId(db, memberKey, peerId) != null) {
1284
- log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
1285
- return;
1286
- }
1287
-
1288
- const stampId = generateStrandStampId();
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);
1300
- log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1301
- }
1302
-
1303
- /**
1304
- * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
1305
- * in JavaScript.
1306
- *
1307
- * Deliberately does NOT filter on the full composite primary key. `MemberPeer`'s PK
1308
- * is `(MemberKey, PeerId)`, so a `where MemberKey = ? and PeerId = ?` predicate puts
1309
- * an equality on BOTH key columns — which the optimystic virtual-table module reports
1310
- * as fully handled and serves via a single-key point lookup (one `find` descent), with
1311
- * the SQL engine adding no filter of its own. On a networked strand that descent is not
1312
- * reliable: a miss returns zero rows for a row that provably exists.
1313
- *
1314
- * Filtering on only the LEADING key column is a partial PK match, which the same module
1315
- * explicitly declines to handle — it falls through to a table scan and the SQL engine
1316
- * applies `MemberKey = ?` itself. No seek is involved, so no seek can miss. The member key
1317
- * is then re-compared here in JavaScript, so correctness depends only on the scan returning
1318
- * a SUPERSET of the matching rows — the weakest possible assumption about the storage layer.
1319
- * The `where` clause only trims what crosses into JS — it is NOT pushed down (see the cost
1320
- * note below) — so it is not a correctness dependency: a dropped or mis-applied predicate
1321
- * cannot produce a row attributed to the wrong member.
1322
- */
1323
- async function* scanMemberPeers(db: Database, memberKey: string): AsyncGenerator<{ peerId: string; stampId: string }> {
1324
- // NOTE: because the predicate is not pushed down, the storage layer walks the WHOLE
1325
- // MemberPeer table (every member's rows) per call and the SQL engine filters. Fine at
1326
- // strand scale; if MemberPeer ever grows large, the fix is a reliable composite-key
1327
- // seek, not a bigger scan.
1328
- // NOTE: if a secondary index on MemberPeer.MemberKey is ever added, this query stops
1329
- // being a scan and becomes an index seek — re-introducing the seek dependency this
1330
- // shape exists to remove.
1331
- for await (const row of db.eval(
1332
- 'select MemberKey, PeerId, StampId from Strand.MemberPeer where MemberKey = ?',
1333
- [memberKey],
1334
- )) {
1335
- if (row.MemberKey === memberKey) {
1336
- yield { peerId: row.PeerId as string, stampId: row.StampId as string };
1337
- }
1338
- }
1339
- }
1340
-
1341
- /**
1342
- * Every `PeerId` currently bound to `memberKey`, in storage order.
1343
- *
1344
- * The enumeration side of {@link removeMemberPeer}: a manager cleaning up after a
1345
- * revocation knows only the departed member's key, never which devices it registered, so
1346
- * cleanup is `listMemberPeers` then one `removeMemberPeer` per id. Reads only — the rows
1347
- * it returns may name a `MemberKey` with no `Member` row (see the `MemberPeer` table NOTE
1348
- * in the schema), so this is NOT a membership test.
1349
- *
1350
- * @param db - The closed strand's database.
1351
- * @param memberKey - The `MemberPeer.MemberKey` to enumerate.
1352
- * @returns The bound peer ids, empty if the member has none.
1353
- */
1354
- export async function listMemberPeers(db: Database, memberKey: string): Promise<string[]> {
1355
- const peerIds: string[] = [];
1356
- for await (const peer of scanMemberPeers(db, memberKey)) {
1357
- peerIds.push(peer.peerId);
1358
- }
1359
- return peerIds;
1360
- }
1361
-
1362
- /**
1363
- * The live StampId of the `MemberPeer` row for this `(MemberKey, PeerId)`, or
1364
- * `undefined` if no such row is visible — doubling as the existence probe.
1365
- *
1366
- * NOTE: check-then-write is not atomic. Two nodes registering the same
1367
- * (MemberKey, PeerId) concurrently can both observe "absent"; the primary key is the
1368
- * real backstop. The existence half of this guard is for the sequential restart /
1369
- * re-register path.
1370
- */
1371
- async function memberPeerStampId(db: Database, memberKey: string, peerId: string): Promise<string | undefined> {
1372
- for await (const peer of scanMemberPeers(db, memberKey)) {
1373
- if (peer.peerId === peerId) {
1374
- return peer.stampId;
1375
- }
1376
- }
1377
- return undefined;
1378
- }
1379
-
1380
- /** Parameters for the SELF branch of {@link removeMemberPeer}. */
1381
- export interface RemoveOwnPeerParams {
1382
- /**
1383
- * The member's OWN strand keypair. Its `publicKeyB64` is the `MemberPeer.MemberKey`
1384
- * of the row being deleted, and it signs the remove-tagged digest
1385
- * `digest('Strand.MemberPeer', 'remove', MemberKey, PeerId, StampId)` — the self
1386
- * branch of `MemberPeer.Authorized` verifies against the row's own member key, so
1387
- * no OTHER member's row is reachable this way, and the tag + stamp binding keep a
1388
- * captured registration approval from doubling as a removal (and vice versa).
1389
- */
1390
- memberKeyPair: Ed25519KeyPair;
1391
- /** The `PeerId` of the binding to delete. */
1392
- peerId: string;
1393
- }
1394
-
1395
- /** Parameters for the MANAGER branch of {@link removeMemberPeer}. */
1396
- export interface RemoveMemberPeerByManagerParams {
1397
- /**
1398
- * The clearing manager's strand keypair. Its `publicKeyB64` must be a PRE-EXISTING
1399
- * `Strand.Manager` row (the manager branch reads `committed.Manager`, so a manager
1400
- * seated in the same transaction cannot authorize); it signs the
1401
- * manager-remove-tagged digest over the exact row and its live stamp, and files
1402
- * the accompanying tombstone.
1403
- */
1404
- managerKeyPair: Ed25519KeyPair;
1405
- /** The `MemberPeer.MemberKey` whose binding is being cleared. */
1406
- memberKey: string;
1407
- /** The `PeerId` of the binding to delete. */
1408
- peerId: string;
1409
- }
1410
-
1411
- /**
1412
- * Either shape accepted by {@link removeMemberPeer}. A discriminated union rather than
1413
- * one opaque keypair: the two branches sign DIFFERENT action tags (`'remove'` self vs
1414
- * `'manager-remove'`), so each case carries exactly the fields its payload needs.
1415
- */
1416
- export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByManagerParams;
1417
-
1418
- /**
1419
- * Delete a `MemberPeer` binding — either the member clearing its own, or a manager
1420
- * clearing another member's.
1421
- *
1422
- * Reads the row's live StampId first (absent → quiet no-op, mirroring
1423
- * {@link registerMemberPeer}'s insert-if-absent, so a repeated or restarted cleanup
1424
- * is not a silent zero-row "success" the caller cannot distinguish from a real one),
1425
- * then runs ONE transaction pairing the delete with its `Strand.Revocation`
1426
- * tombstone — `MemberPeer.RevocationRecorded` requires it, and the retirement is
1427
- * what keeps the captured registration approval from re-binding the cleared row.
1428
- *
1429
- * `MemberPeer` rows do NOT cascade when the member is revoked (`MemberExists` runs on
1430
- * insert only, and nothing deletes them), so a removed member's peer bindings survive
1431
- * as orphans — enumerate them with {@link listMemberPeers}, which is how a manager
1432
- * discovers what to clear. Only that member can sign the self branch, and a member being
1433
- * removed against its will has no reason to cooperate — hence the manager branch, which
1434
- * is the cleanup path after a revocation.
1435
- *
1436
- * The manager branch is deliberately NOT gated on the member already being gone: a
1437
- * manager may clear a still-present member's binding too, matching "any manager can
1438
- * remove any member". It verifies the manager-remove-tagged
1439
- * `digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId)`
1440
- * against a `committed.Manager` row; the self branch verifies the remove-tagged
1441
- * sibling against the row's own member key.
1442
- *
1443
- * Cleanup is an explicit follow-up call by the revoking manager, never a cascade inside
1444
- * {@link revokeMember}: a revocation that also had to clear an unbounded number of peer
1445
- * rows in the same transaction would couple two concerns and make its failure modes
1446
- * harder to reason about.
1447
- *
1448
- * @param db - The closed strand's database.
1449
- * @param params - Either the owning member's keypair, or a manager's keypair plus the
1450
- * target member key.
1451
- * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1452
- * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
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.
1456
- */
1457
- export async function removeMemberPeer(
1458
- db: Database,
1459
- params: RemoveMemberPeerParams,
1460
- options?: StrandWriteOptions,
1461
- ): Promise<void> {
1462
- const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
1463
- const { peerId } = params;
1464
-
1465
- const stampId = await memberPeerStampId(db, memberKey, peerId);
1466
- if (stampId == null) {
1467
- log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
1468
- return;
1469
- }
1470
-
1471
- const removal = 'memberKeyPair' in params
1472
- ? ownMemberPeerRemovalStatements(params, stampId)
1473
- : memberPeerRemovalByManagerStatements(params, stampId);
1474
- await execStrandTransaction(db, removal, options);
1475
-
1476
- // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
1477
- // optimystic vtab module serves via a single-key seek that can MISS on a networked
1478
- // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
1479
- // reports success. `Revocation.RowIsGone` now catches that first — the tombstone filed
1480
- // in the same transaction refuses to retire a stamp whose row is still visible, so the
1481
- // commit above fails loudly and this re-check is belt-and-braces behind it. Tracked as
1482
- // `debt-composite-pk-point-lookup-unreliable-untracked`; this is an availability
1483
- // failure mode, never a security one (a miss removes nothing, it never over-removes).
1484
- if (await memberPeerStampId(db, memberKey, peerId) != null) {
1485
- throw new Error(`MemberPeer (${memberKey}, ${peerId}) still present after delete`);
1486
- }
1487
- log('Removed MemberPeer (%s, %s)', memberKey, peerId);
1488
- }
1489
-
1490
- /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
1491
- function ownMemberPeerRemovalStatements(params: RemoveOwnPeerParams, stampId: string): StrandStatements {
1492
- const { memberKeyPair, peerId } = params;
1493
- const memberKey = memberKeyPair.publicKeyB64;
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));
1505
- }
1506
-
1507
- /**
1508
- * The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
1509
- *
1510
- * NOTE: clearing an ORPHANED binding (one whose member was already removed) also
1511
- * forgets the NETWORK DENIAL of that peer id. The orphaned `MemberPeer` row IS
1512
- * the record the strand revoked-peer gate derives its deny set from
1513
- * (`strand-revocation-enforcer.ts`) — the `Strand.Revocation` tombstone keeps
1514
- * only the stamp, not the peer id — so once the orphan is cleared, remaining
1515
- * members' nodes resume answering and dialing that peer. Only clear a binding
1516
- * that should never have existed, or a peer that is truly gone.
1517
- */
1518
- function memberPeerRemovalByManagerStatements(
1519
- params: RemoveMemberPeerByManagerParams,
1520
- stampId: string,
1521
- ): StrandStatements {
1522
- const { managerKeyPair, memberKey, peerId } = params;
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));
1538
- }
1539
-
1540
- // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
1541
-
1542
- /**
1543
- * The live `(Generation, StampId)` of the `Strand.Manager` row keyed by
1544
- * `memberKey`, or `undefined` if no such row is visible.
1545
- *
1546
- * `Manager`'s primary key is the single `MemberKey` column, so any where-equality
1547
- * on it is a full-PK point lookup — unreliable on a networked strand (see
1548
- * {@link scanMemberPeers}). An unfiltered scan + JavaScript filter instead, same
1549
- * as {@link memberStampId}. Serves both {@link addManager} (the authorizer's
1550
- * generation) and {@link removeManager} (the target's stamp).
1551
- */
1552
- async function managerRow(db: Database, memberKey: string): Promise<{ generation: number; stampId: string } | undefined> {
1553
- for await (const row of db.eval('select MemberKey, Generation, StampId from Strand.Manager')) {
1554
- if (row.MemberKey === memberKey) {
1555
- return { generation: Number(row.Generation), stampId: row.StampId as string };
1556
- }
1557
- }
1558
- return undefined;
1559
- }
1560
-
1561
- /** Parameters for {@link addManager}. */
1562
- export interface AddManagerParams {
1563
- /**
1564
- * An EXISTING manager's strand keypair. Its `publicKeyB64` must already be a
1565
- * `Strand.Manager` row (the existing-manager branch of `Manager.Authorized`
1566
- * rejects a non-manager once the founder manager exists); it signs the
1567
- * add-tagged digest over the new manager key, generation, and fresh stamp,
1568
- * and is bound as `context.ManagerKey`.
1569
- */
1570
- byManagerKeyPair: Ed25519KeyPair;
1571
- /** The member key to promote — the new `Manager.MemberKey` row. */
1572
- newManagerKey: string;
1573
- }
1574
-
1575
- /**
1576
- * Promote a key that is ALREADY a `Strand.Member` to `Manager` on the signature of
1577
- * an existing manager — use {@link admitManager} to admit and promote atomically.
1578
- *
1579
- * The promotion branch of `Manager.Authorized` requires the authorizer to be a
1580
- * `Manager` row of STRICTLY SMALLER `Generation` than the new row, verifying
1581
- * `digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId)`
1582
- * against `context.ManagerKey`. So this writer reads the authorizer's generation,
1583
- * seats the new manager at that value + 1 (the natural successor; the schema
1584
- * enforces only the strict ordering), mints a fresh stamp, and signs the
1585
- * add-tagged digest over all three: the generation inside the signed digest keeps
1586
- * a captured promotion from replaying at a different generation, and the stamp
1587
- * keeps it from re-seating a second incarnation once this one is removed.
1588
- * `new.Generation` is an SQL INTEGER digest arg — the digest framing is
1589
- * type-tagged, so the TS side signs over the NUMBER itself
1590
- * (see {@link signStrandApproval}).
1591
- *
1592
- * The strict ordering — not this writer — is what makes a same-transaction takeover
1593
- * impossible; the `Manager.Generation` column comment in `schemas/strand.qsql` carries
1594
- * that argument. All this writer owes it is a generation strictly above the
1595
- * authorizer's, inside the signed payload.
1596
- *
1597
- * When the authorizer has NO `Manager` row (a non-manager signer, or an open
1598
- * strand with no managers at all), the scan finds nothing and the writer falls
1599
- * back to generation 1 and issues the insert anyway — deliberately letting the
1600
- * SCHEMA be the rejector (`Manager.Authorized` / `OnlyClosed`), not a
1601
- * writer-thrown error, so enforcement is pinned where it actually lives.
1602
- *
1603
- * @param db - The closed strand's database (founder manager already seated).
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}).
1606
- * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
1607
- * `newManagerKey` holds no `Member` row (admit it first, or use
1608
- * {@link admitManager}); the insert rolls back.
1609
- */
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> {
1627
- const { byManagerKeyPair, newManagerKey } = params;
1628
- const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
1629
- // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
1630
- // to its authorizer and the schema rejects. Unreachable while generations only ever
1631
- // grow by 1 from 0, but the schema enforces ordering, not adjacency, so a manager may
1632
- // seat a successor at any larger value; if arbitrary generations ever become writable
1633
- // from outside this function, clamp or reject here rather than emitting a dead row.
1634
- const generation = authorizer == null ? 1 : authorizer.generation + 1;
1635
- const stampId = generateStrandStampId();
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
- };
1651
- }
1652
-
1653
- /** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
1654
- export type AdmitManagerParams = AddManagerParams;
1655
-
1656
- /**
1657
- * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
1658
- * pair for a key that is not in the strand yet. {@link addManager} alone promotes a
1659
- * key that is already a member.
1660
- *
1661
- * ONE transaction, because `Manager.MemberExists` is a deferred check reading the LIVE
1662
- * `Member` table: the sibling `Member` insert satisfies it at the shared commit, and a
1663
- * rejection of either half rolls BOTH rows back — never a `Member` row seated by a
1664
- * failed promotion, nor a `Manager` row with no member behind it.
1665
- *
1666
- * NO new authority: both halves are signed by the SAME manager, over two distinct
1667
- * action-tagged digests — `digest('Strand.Member', 'add', …)` for the admission and
1668
- * `digest('Strand.Manager', 'add', …)` for the promotion — each bound to its own row's
1669
- * fresh stamp. This composes exactly the two approvals that manager could already
1670
- * issue separately.
1671
- *
1672
- * The promoting manager must be a PRE-transaction manager: the direct-admit branch of
1673
- * `Member.Authorized` reads `committed.Manager`, so `admitManager` cannot be chained —
1674
- * a manager seated by an `admitManager` earlier in the SAME transaction cannot admit
1675
- * the next one. (A same-transaction promotion CHAIN rooted at a pre-existing manager
1676
- * is fine; only the `Member` half needs the committed authorizer.)
1677
- *
1678
- * NOT insert-if-absent: a repeat call for a key that is already a member fails on the
1679
- * `Member` primary key, matching {@link addMemberByManager}'s unguarded shape.
1680
- *
1681
- * @param db - The closed strand's database (founder manager already seated).
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}).
1684
- * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1685
- * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1686
- */
1687
- export async function admitManager(
1688
- db: Database,
1689
- params: AdmitManagerParams,
1690
- options?: StrandWriteOptions,
1691
- ): Promise<void> {
1692
- const { byManagerKeyPair, newManagerKey } = params;
1693
- const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1694
- const promotion = await managerAddStatement(db, params);
1695
- await execStrandTransaction(db, combineStatements(admission, promotion), options);
1696
- log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1697
- }
1698
-
1699
- /** Parameters for {@link removeManager}. */
1700
- export interface RemoveManagerParams {
1701
- /**
1702
- * The keypair authorizing the removal, bound as `context.ManagerKey`. For an
1703
- * ADMIN removal this is a DIFFERENT existing manager, signing the
1704
- * remove-tagged digest (satisfying the existing-manager branch); for a
1705
- * SELF-resignation it is the target's OWN keypair (`publicKeyB64 ===
1706
- * targetManagerKey`), signing the resign-tagged digest (satisfying the
1707
- * former-manager self branch). The writer picks the tag by comparing the keys —
1708
- * see {@link removeManager}. Either way this keypair also files the
1709
- * accompanying tombstone, which requires it to be a PRE-transaction MEMBER
1710
- * (`Revocation.Authorized` reads `committed.Member`) — true for every manager,
1711
- * since managers are members.
1712
- */
1713
- byManagerKeyPair: Ed25519KeyPair;
1714
- /** The `Manager.MemberKey` row to delete. */
1715
- targetManagerKey: string;
1716
- }
1717
-
1718
- /**
1719
- * Remove a `Manager` row — either an admin removing a different manager or a
1720
- * manager resigning itself.
1721
- *
1722
- * Reads the target row's live `(Generation, StampId)` first (absent → quiet
1723
- * no-op, same restart-safe shape as {@link revokeMember}), then runs ONE
1724
- * transaction pairing the delete with its `Strand.Revocation` tombstone
1725
- * (`Manager.RevocationRecorded`). The two delete-side branches of
1726
- * `Manager.Authorized` verify DIFFERENT action-tagged digests, each bound to
1727
- * `(old.MemberKey, old.StampId)`:
1728
- * - **Admin removal** (`byManagerKeyPair` is a different existing manager): the
1729
- * removal branch verifies `digest('Strand.Manager', 'remove', old.MemberKey,
1730
- * old.StampId)` against `A.MemberKey = context.ManagerKey`. Deliberately NO
1731
- * generation condition: a later-generation manager may remove an
1732
- * earlier-generation one (generation is lineage, not privilege), and deletes
1733
- * are safe once inserts are — every accepting branch requires a `Manager` row
1734
- * in the post-image, and the promotion branch's generation ordering keeps
1735
- * attacker rows out of it. The stamp binding is what makes a captured removal
1736
- * single-use: a re-promoted manager carries a fresh stamp the old approval
1737
- * does not match — closing the removal half of
1738
- * `bug-strand-manager-authority-antireplay` (the insert half was already
1739
- * closed by the generation-bearing promotion payload).
1740
- * - **Self-resignation** (`byManagerKeyPair` IS the target): the former-manager
1741
- * self branch verifies `digest('Strand.Manager', 'resign', old.MemberKey,
1742
- * old.StampId)` against `old.MemberKey` itself. The distinct `'resign'` tag is
1743
- * belt-and-braces (different verifying keys); the anti-replay fix is the stamp.
1744
- *
1745
- * The caller selects the case purely by which keypair it passes; the only
1746
- * branching here is the action tag, chosen by comparing the signer to the target.
1747
- *
1748
- * Deferred (subquery-bearing) CHECK constraints are evaluated on DELETE as well
1749
- * as INSERT by Quereus plus the Optimystic vtab session — enforcement lives above
1750
- * the transactor (proven on the network transactor by
1751
- * `strand-membership-network-transactor-parity.spec.ts`), so `Manager.Authorized`
1752
- * — deferred — IS enforced here: a signer that is neither an existing manager nor
1753
- * the target itself is rejected at commit.
1754
- *
1755
- * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
1756
- * branch of `Manager.Authorized` requires at least one manager in the POST-delete
1757
- * image, so a resignation that would empty the `Manager` table is schema-rejected;
1758
- * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
1759
- * that permanently freezes admission. This writer refuses the sole-manager
1760
- * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
1761
- * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
1762
- * regardless. Reading the count inside a caller-joined transaction is correct: an
1763
- * add-then-resign hand-off composed in one transaction sees 2 and passes, while
1764
- * the already-rejected delete-then-add swap sees 1 and is refused here as well as
1765
- * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1766
- * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1767
- * that drops the count toward zero: a second-to-last removal is authorized
1768
- * exactly like any other.
1769
- *
1770
- * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
1771
- * and resign SECOND. A same-transaction delete-then-insert swap is rejected — the
1772
- * bootstrap branch is gated to the founding state (at most one `Member`), and the
1773
- * successor's insert has no other manager to authorize it once the sole manager's
1774
- * row is gone.
1775
- *
1776
- * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
1777
- * transaction, so two nodes concurrently removing different managers can each see
1778
- * a survivor and still converge to zero — an unintended seal. Noted in the schema
1779
- * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
1780
- * attempted here.
1781
- *
1782
- * @param db - The closed strand's database.
1783
- * @param params - The authorizing keypair and the target manager key.
1784
- * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1785
- * @throws If the signer IS the target and it is the sole manager (before writing —
1786
- * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1787
- * rejects (a signer that is neither another existing manager nor the target
1788
- * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1789
- * whole delete+tombstone transaction rolls back.
1790
- */
1791
- export async function removeManager(
1792
- db: Database,
1793
- params: RemoveManagerParams,
1794
- options?: StrandWriteOptions,
1795
- ): Promise<void> {
1796
- const { byManagerKeyPair, targetManagerKey } = params;
1797
- const target = await managerRow(db, targetManagerKey);
1798
- if (target == null) {
1799
- log('Manager %s already absent; skipping removal', targetManagerKey);
1800
- return;
1801
- }
1802
- const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1803
- if (tag === 'resign' && await strandTableCount(db, 'Manager') === 1) {
1804
- throw new Error(
1805
- 'Cannot resign as the SOLE manager: an ordinary resignation never empties the '
1806
- + 'Manager table. To permanently freeze admission instead, call sealStrand.',
1807
- );
1808
- }
1809
- const signature = signStrandApproval(
1810
- ['Strand.Manager', tag, targetManagerKey, target.stampId],
1811
- byManagerKeyPair.privateKeyB64,
1812
- );
1813
- await execStrandTransaction(
1814
- db,
1815
- managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId),
1816
- options,
1817
- );
1818
- log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1819
- }
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
-
1844
- // ── Sealing (the sole manager permanently freezes admission) ──────────────────
1845
-
1846
- /** Parameters for {@link sealStrand}. */
1847
- export interface SealStrandParams {
1848
- /**
1849
- * The SOLE manager's strand keypair. Its `publicKeyB64` must be the one live
1850
- * `Strand.Manager` row; it signs the seal-tagged digest over its own key and
1851
- * live stamp, and files the accompanying tombstone (it is still a committed
1852
- * member, which is what `Revocation.Authorized` checks).
1853
- */
1854
- managerKeyPair: Ed25519KeyPair;
1855
- }
1856
-
1857
- /**
1858
- * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1859
- * membership.
1860
- *
1861
- * A sealed strand has zero `Manager` rows, and every admission path — issuing or
1862
- * cancelling an invitation, consuming one issued BEFORE the seal
1863
- * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
1864
- * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
1865
- * everyone who contributed: no key holds the power to admit a party who would then
1866
- * read the strand's whole history. Sealing is irreversible — the founding branch
1867
- * of `Manager.Authorized` closes for good once any manager stamp is retired into
1868
- * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
1869
- * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
1870
- * `Revocation` tombstones.
1871
- *
1872
- * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
1873
- * when the POST-image manager count is ZERO — a distinct action tag from
1874
- * `'resign'`, so a captured resignation approval can never seal and a raw writer
1875
- * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
1876
- * `Strand.Revocation` tombstone in one transaction.
1877
- *
1878
- * The TS count/identity checks below are UX guards, not the trust boundary — the
1879
- * schema rejects a mis-tagged or mis-counted approval regardless of what this
1880
- * writer (or any raw writer) presents.
1881
- *
1882
- * NOTE: a seal approval is a bearer token for its row incarnation, like every other
1883
- * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
1884
- * satisfied for EVERY row deleted in the same transaction, so N managers each
1885
- * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
1886
- * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
1887
- * while its caller is the SOLE manager, and spends it immediately, so a second
1888
- * manager never has one to combine. If seal-approval MINTING is ever split from
1889
- * spending — offline signing, a two-phase seal, a queued approval — one manager
1890
- * holding another's unspent seal approval could freeze the strand irreversibly
1891
- * without its current consent; re-gate here (or narrow the schema branch) then.
1892
- *
1893
- * @param db - The closed strand's database.
1894
- * @param params - The sole manager's own keypair.
1895
- * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1896
- * @throws If more than one manager exists (the caller wants {@link removeManager}),
1897
- * or the supplied keypair holds no `Manager` row while one exists, or the strand
1898
- * holds no `Manager` row and has never retired one (not founded yet — see
1899
- * {@link isStrandSealed}), or any schema constraint rejects; the whole
1900
- * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
1901
- * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1902
- * and {@link removeManager}'s absent-row no-op).
1903
- */
1904
- export async function sealStrand(
1905
- db: Database,
1906
- params: SealStrandParams,
1907
- options?: StrandWriteOptions,
1908
- ): Promise<void> {
1909
- const { managerKeyPair } = params;
1910
- const managerCount = await strandTableCount(db, 'Manager');
1911
- if (managerCount === 0) {
1912
- // Zero managers is NOT by itself a seal — a closed strand between its Header
1913
- // and Manager founding inserts looks identical. The retired `Manager` stamp is
1914
- // what separates "frozen forever" from "still foundable"; returning quietly on
1915
- // the latter would report a seal that never happened.
1916
- if (await strandHasManagerRevocation(db)) {
1917
- log('Strand already sealed (no Manager rows, manager stamp retired); skipping seal');
1918
- return;
1919
- }
1920
- throw new Error(
1921
- 'Cannot seal: the strand holds no Manager row and has never retired one — it is '
1922
- + 'not founded yet, not sealed. Bootstrap the founding manager first.',
1923
- );
1924
- }
1925
- if (managerCount > 1) {
1926
- throw new Error(
1927
- `Cannot seal: ${managerCount} managers exist and sealing is the SOLE manager's act. `
1928
- + 'The other managers must step down first (removeManager).',
1929
- );
1930
- }
1931
- const self = await managerRow(db, managerKeyPair.publicKeyB64);
1932
- if (self == null) {
1933
- throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1934
- }
1935
- const signature = signStrandApproval(
1936
- ['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
1937
- managerKeyPair.privateKeyB64,
1938
- );
1939
- await execStrandTransaction(
1940
- db,
1941
- managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId),
1942
- options,
1943
- );
1944
- log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1945
- }
1946
-
1947
- /**
1948
- * Whether the strand is `Type = 'c'` (closed). Reads the singleton `Header` via
1949
- * the same `db.eval` scan idiom as {@link strandTableCount}; a strand with no
1950
- * `Header` row yet reports `false`.
1951
- */
1952
- async function strandIsClosed(db: Database): Promise<boolean> {
1953
- for await (const row of db.eval('select Type from Strand.Header')) {
1954
- return row.Type === 'c';
1955
- }
1956
- return false;
1957
- }
1958
-
1959
- /**
1960
- * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
1961
- * has retired at least one `Manager` stamp into `Strand.Revocation`.
1962
- *
1963
- * All three conjuncts are load-bearing:
1964
- * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
1965
- * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
1966
- * strand as sealed.
1967
- * - the manager count — sealing IS the empty `Manager` table.
1968
- * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
1969
- * what makes it permanent is the founding branch of `Manager.Authorized`
1970
- * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
1971
- * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
1972
- * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
1973
- * `Member` and `Manager` as three sequential statements, so the window between
1974
- * the first and the last is exactly "closed, zero managers, still foundable".
1975
- *
1976
- * Reads locally visible rows — a node that has not yet converged on the seal
1977
- * still reports `false` (the schema's seal-propagation NOTE).
1978
- *
1979
- * @param db - The strand's database.
1980
- * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
1981
- * permanently closed its founding branch.
1982
- */
1983
- export async function isStrandSealed(db: Database): Promise<boolean> {
1984
- return await strandIsClosed(db)
1985
- && await strandTableCount(db, 'Manager') === 0
1986
- && await strandHasManagerRevocation(db);
1987
- }
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { TransactionActiveError } from '@quereus/quereus';
4
+ import type { Database, SqlValue } from '@quereus/quereus';
5
+ import { digest, sign, verify, generatePrivateKey, getPublicKey, randomBytes } from '@optimystic/quereus-plugin-crypto';
6
+ import type { SAppConfig } from './types.js';
7
+ import type { Ed25519KeyPair } from './ed25519-key.js';
8
+ import { canonicalDatetime } from './canonical-datetime.js';
9
+
10
+ const log = debug('sereus:cadre:strand-membership');
11
+
12
+ /**
13
+ * The engine (rules-logic system) assumed to manage every strand. Persisted in
14
+ * `Strand.Header.Engine`.
15
+ *
16
+ * PLACEHOLDER SEAM: there is no engine-selection mechanism yet — Quereus is the
17
+ * only engine. When a real engine-config seam lands, the founder bootstrap should
18
+ * read the strand's chosen engine from there instead of this constant. Documented
19
+ * deliberately so the column is non-null and self-describing in the meantime.
20
+ */
21
+ export const STRAND_ENGINE = 'quereus';
22
+
23
+ /**
24
+ * The pinned engine version recorded in `Strand.Header.EngineVersion`. A pinned
25
+ * string for the same reason as {@link STRAND_ENGINE}: it is a documented
26
+ * placeholder until an engine-selection seam supplies the real running version.
27
+ */
28
+ export const STRAND_ENGINE_VERSION = '0.1.0';
29
+
30
+ /**
31
+ * The single-payload ed25519 signer the `Strand.Invite`/`Strand.ConsumedInvite`
32
+ * handshake constraints verify against.
33
+ *
34
+ * Those constraints sign a single SHA-256 digest over a `'|'`-joined payload
35
+ * (e.g. `InviteValid` verifies
36
+ * `verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), ...)`).
37
+ * So the signer hashes the payload to raw bytes and ed25519-signs *those bytes*:
38
+ * SQL `digest(...)`'s default output is base64url and `verify(...)`'s default
39
+ * `inputEncoding` is base64url, so signer and verifier operate on identical bytes.
40
+ * The membership/RBAC tables (Member/Manager/MemberPeer/Revocation) instead use
41
+ * domain/action-tagged VARIADIC digests — see {@link signStrandApproval}.
42
+ *
43
+ * All strand keys are ed25519, so the explicit `'ed25519'` curve arg is mandatory
44
+ * (the crypto plugin otherwise defaults to secp256k1). Mirrors the proven
45
+ * `signItem` helper in the `rbac-signed-write` integration scenario.
46
+ *
47
+ * @param payload - The `'|'`-joined payload the matching constraint hashes.
48
+ * @param privateKeyB64 - The signer's base64url ed25519 private seed.
49
+ * @returns The base64url ed25519 signature over the payload's SHA-256 digest.
50
+ */
51
+ export function signStrandPayload(payload: string, privateKeyB64: string): string {
52
+ const hashBytes = digest([payload], 'sha256', 'bytes') as Uint8Array;
53
+ return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
54
+ }
55
+
56
+ /**
57
+ * The verifier counterpart to {@link signStrandPayload}.
58
+ *
59
+ * Mirrors what the single-payload `Strand.*` constraints compute:
60
+ * `verify(digest(payload), signature, publicKey, 'ed25519')`.
61
+ * SQL `digest`'s default output and `verify`'s default `inputEncoding`/`sigEncoding`/
62
+ * `keyEncoding` are all base64url, so the off-engine check operates on the exact
63
+ * same bytes the in-engine CHECK does. Used by the off-engine `MemberVerifier`
64
+ * (pre-flight registration checks) so the on-engine constraint is not the only
65
+ * place a member self-proof is validated.
66
+ *
67
+ * @param payload - The `'|'`-joined payload the matching constraint hashes.
68
+ * @param signatureB64 - The base64url ed25519 signature to check.
69
+ * @param publicKeyB64 - The base64url ed25519 public key to verify against.
70
+ * @returns `true` iff the signature is valid for the payload under that key.
71
+ */
72
+ export function verifyStrandPayload(payload: string, signatureB64: string, publicKeyB64: string): boolean {
73
+ const payloadDigest = digest([payload], 'sha256', 'base64url') as string;
74
+ return verify(payloadDigest, signatureB64, publicKeyB64, 'ed25519', 'base64url', 'base64url', 'base64url');
75
+ }
76
+
77
+ /**
78
+ * Sign a domain/action-tagged `Strand.*` membership/RBAC approval.
79
+ *
80
+ * Every `Authorized` branch on `Member`/`Manager`/`MemberPeer`/`Revocation`
81
+ * verifies a VARIADIC digest — `digest('<domain>', '<action>', <field>, ...,
82
+ * <StampId>)` — whose leading two elements are fixed literals (the domain tag,
83
+ * e.g. `'Strand.Member'`, and an action tag, e.g. `'add'`) and whose trailing
84
+ * element is the row's per-incarnation StampId. The tags keep an approval from
85
+ * verifying against any rule but the one it was minted for (the idiom of the
86
+ * control layer's `buildAuthorizationMessage`); the StampId binding makes it
87
+ * single-use — it authorizes exactly one incarnation of one row, and dies when
88
+ * that row's stamp is retired into `Strand.Revocation`.
89
+ *
90
+ * The caller passes the digest elements exactly as the matching SQL constraint
91
+ * lists them (each SQL argument = one array element), PRESERVING TYPE: the
92
+ * crypto plugin's digest framing is type-tagged (INTEGER 1 and TEXT '1' encode
93
+ * differently, deliberately), so `Manager.Authorized`'s promotion branch —
94
+ * whose digest includes the INTEGER `new.Generation` — must be signed over the
95
+ * NUMBER, not its string form. Integer `number` and `bigint` encode
96
+ * identically in that framing, so JS-side numbers match however the engine
97
+ * represents the SQL integer.
98
+ *
99
+ * Like {@link signStrandPayload}, the raw digest BYTES are ed25519-signed
100
+ * directly, matching SQL `verify(...)`'s default base64url input decoding.
101
+ *
102
+ * @param fields - The digest elements, in constraint order (tags first, StampId last).
103
+ * @param privateKeyB64 - The signer's base64url ed25519 private seed.
104
+ * @returns The base64url ed25519 signature over the tagged digest.
105
+ */
106
+ export function signStrandApproval(fields: ReadonlyArray<string | number>, privateKeyB64: string): string {
107
+ const hashBytes = digest(fields, 'sha256', 'bytes') as Uint8Array;
108
+ return sign(hashBytes, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
109
+ }
110
+
111
+ /**
112
+ * Mint a fresh per-row authorization nonce (StampId) for a `Strand.Member`,
113
+ * `Strand.Manager`, or `Strand.MemberPeer` row.
114
+ *
115
+ * 256 bits of CSPRNG output, base64url-encoded — enough entropy that a future
116
+ * legitimate stamp cannot be guessed and pre-planted into `Strand.Revocation`
117
+ * (the `RowIsGone` comment in `schemas/strand.qsql` leans on exactly this).
118
+ * Plain randomness, deliberately NOT the control layer's peer-derived
119
+ * `generateStampId`: strand rows are not per-peer, so entropy alone carries
120
+ * the uniqueness.
121
+ *
122
+ * @returns A fresh base64url stamp (43 chars for 32 bytes).
123
+ */
124
+ export function generateStrandStampId(): string {
125
+ // randomBytes' first arg is BITS, not bytes — 256 bits = 32 bytes.
126
+ const bytes = randomBytes(256, 'bytes') as Uint8Array;
127
+ return uint8ArrayToString(bytes, 'base64url');
128
+ }
129
+
130
+ // ── Write batches (every membership write is one indivisible transaction) ──────
131
+
132
+ /**
133
+ * A membership writer that runs only in a transaction of its own found another
134
+ * transaction already open on the strand database, and wrote nothing. Raised only
135
+ * under `joinOpenTransaction: false` (see {@link StrandWriteOptions}) — the background
136
+ * callers, which treat it as "the app is mid-transaction; try again later".
137
+ */
138
+ export class StrandTransactionBusyError extends Error {
139
+ constructor(options?: ErrorOptions) {
140
+ super(
141
+ 'Another transaction is open on the strand database. This membership write runs only in a '
142
+ + 'transaction of its own, so nothing was written; retry once that transaction closes.',
143
+ options,
144
+ );
145
+ this.name = 'StrandTransactionBusyError';
146
+ }
147
+ }
148
+
149
+ /** Options every transactional membership writer accepts. */
150
+ export interface StrandWriteOptions {
151
+ /**
152
+ * Default true: when a transaction is already open on the database, run the writer's
153
+ * statements inside it. The caller composes several writers in one transaction (e.g. a
154
+ * resign + revoke pair that must land together) and owns its commit and rollback, so
155
+ * the deferred constraints fire once, at the caller's commit.
156
+ *
157
+ * Background callers (the membership reconciler, the unpublish binding cleanup) pass
158
+ * false: they share the database with the app and must never join a transaction
159
+ * someone else opened, so the writer throws {@link StrandTransactionBusyError} instead.
160
+ */
161
+ joinOpenTransaction?: boolean;
162
+ }
163
+
164
+ /**
165
+ * SQL statements, each terminated by `;`, plus the named parameters they bind. The unit
166
+ * the writers assemble a batch from: a writer does every read, signature, and
167
+ * `canonicalDatetime` first, and only then issues all its statements in ONE `db.exec`.
168
+ */
169
+ interface StrandStatements {
170
+ sql: string;
171
+ params: Record<string, SqlValue>;
172
+ }
173
+
174
+ /**
175
+ * Join statement fragments into one batch. Named parameters bind across the whole batch,
176
+ * so each fragment builder uses its own parameter names; a name bound twice would
177
+ * silently hand one fragment the other's value, so it throws instead.
178
+ */
179
+ function combineStatements(...fragments: StrandStatements[]): StrandStatements {
180
+ const params: Record<string, SqlValue> = {};
181
+ for (const fragment of fragments) {
182
+ for (const [name, value] of Object.entries(fragment.params)) {
183
+ if (name in params) {
184
+ throw new Error(`Strand write batch binds the parameter :${name} in two statements`);
185
+ }
186
+ params[name] = value;
187
+ }
188
+ }
189
+ return { sql: fragments.map((fragment) => fragment.sql).join('\n'), params };
190
+ }
191
+
192
+ /**
193
+ * Run `statements` as one indivisible transaction on the shared strand database.
194
+ *
195
+ * `exec(..., { transaction: true })` begins, runs, and commits or rolls back the whole
196
+ * batch under a single hold of Quereus's execution mutex. That is what makes the
197
+ * transaction indivisible on a `Database` the app shares with the background writers: no
198
+ * other caller's statement can run inside it, so none is ever swept into a membership
199
+ * write and lost when that write fails — not even a statement that fails before `commit`
200
+ * (a primary-key collision, an immediate CHECK, a storage read error), whose rollback also
201
+ * happens before the mutex is released. A hand-rolled `begin` / … / `commit` batch is not
202
+ * equivalent, and a `beginTransaction()` / `exec` / `commit()` sequence even less so:
203
+ * those are separate mutex acquisitions.
204
+ *
205
+ * Whether a transaction is already open is decided by the batch itself, under the mutex,
206
+ * not by `getAutocommit()` beforehand: that reads false whenever another caller's
207
+ * autocommit statement is merely in flight, which would make a background writer refuse
208
+ * spuriously and a composing caller run its statements as separate autocommits.
209
+ *
210
+ * - No transaction was open: the batch commits or fails as a unit, leaving nothing open.
211
+ * - One was already open, so the batch refuses with `TransactionActiveError` having run
212
+ * nothing. With `joinOpenTransaction: false` that becomes a
213
+ * {@link StrandTransactionBusyError} (with the refusal as `cause`), and the open
214
+ * transaction — someone else's — is left alone. Otherwise the statements run inside it,
215
+ * and its owner commits or rolls back.
216
+ */
217
+ async function execStrandTransaction(
218
+ db: Database,
219
+ statements: StrandStatements,
220
+ options?: StrandWriteOptions,
221
+ ): Promise<void> {
222
+ try {
223
+ await db.exec(statements.sql, statements.params, { transaction: true });
224
+ return;
225
+ } catch (error) {
226
+ if (!(error instanceof TransactionActiveError)) {
227
+ throw error;
228
+ }
229
+ if (options?.joinOpenTransaction === false) {
230
+ throw new StrandTransactionBusyError({ cause: error });
231
+ }
232
+ }
233
+ // NOTE: the join retry cannot ask for `{ transaction: true }` — the point is to run INSIDE the
234
+ // caller's transaction — so it is atomic only for as long as that transaction stays open. Every
235
+ // joining caller today opens its transaction, awaits the writer, then commits, so the
236
+ // transaction cannot close in the window between the refusal above and this line. If a caller
237
+ // ever closes its transaction concurrently with a writer call it did not await, these statements
238
+ // run as separate autocommit statements instead of one unit. The writers whose statements are
239
+ // circularly dependent (`consumeInvite`'s Member + ConsumedInvite, every delete + its Revocation
240
+ // tombstone) fail loudly on their own deferred checks in that case; a writer whose statements are
241
+ // each valid alone (`admitManager`) could commit only the first. If a caller ever needs to write
242
+ // membership rows without awaiting, give it `joinOpenTransaction: false` rather than relying on
243
+ // this path.
244
+ await db.exec(statements.sql, statements.params);
245
+ }
246
+
247
+ /**
248
+ * The `Strand.Revocation` tombstone insert that retires a deleted row's StampId.
249
+ *
250
+ * Always batched with the delete it accompanies — the guarded tables'
251
+ * `RevocationRecorded` (deferred) requires the tombstone at commit, and
252
+ * `Revocation.RowIsGone` (also deferred) requires the row to already be gone, so
253
+ * the pair only ever lands together.
254
+ *
255
+ * `Revocation.Authorized` verifies the retire-tagged digest against a
256
+ * PRE-transaction (`committed.Member`) row, so the retiree must have been a
257
+ * member BEFORE this transaction. Every writer flow satisfies this: managers are
258
+ * members, and a self-departing member is committed until this very transaction
259
+ * removes it. Unlike the sibling tables' nullable contexts, `Revocation`'s
260
+ * context is non-null — both fields are always supplied.
261
+ *
262
+ * @param tableName - Which guarded table the stamp belonged to.
263
+ * @param stampId - The retired stamp.
264
+ * @param retiree - The committed member filing the tombstone (the delete's own signer).
265
+ */
266
+ function revocationStatement(
267
+ tableName: 'Member' | 'Manager' | 'MemberPeer',
268
+ stampId: string,
269
+ retiree: Ed25519KeyPair,
270
+ ): StrandStatements {
271
+ return {
272
+ sql: `insert into Strand.Revocation (TableName, StampId)
273
+ with context MemberKey = :revocationSignerKey, Signature = :revocationSignature
274
+ values (:revocationTable, :revocationStampId);`,
275
+ params: {
276
+ revocationSignerKey: retiree.publicKeyB64,
277
+ revocationSignature: signStrandApproval(['Strand.Revocation', 'retire', tableName, stampId], retiree.privateKeyB64),
278
+ revocationTable: tableName,
279
+ revocationStampId: stampId,
280
+ },
281
+ };
282
+ }
283
+
284
+ /** Parameters for {@link bootstrapFounderMembership}. */
285
+ export interface FounderBootstrapParams {
286
+ /** The strand id — written to `Header.Id`. */
287
+ strandId: string;
288
+ /** Strand type: `'o'` (open, Header only) or `'c'` (closed, Header+Member+Manager). */
289
+ type: 'o' | 'c';
290
+ /** The sApp config whose id/version/schema/signature populate the Header. */
291
+ sApp: SAppConfig;
292
+ /**
293
+ * The founder's derived strand keypair (from {@link strandMemberKeyPair}). Its
294
+ * `publicKeyB64` becomes the founding `Member.Key` and `Manager.MemberKey`.
295
+ * Required for a closed strand; ignored for an open strand.
296
+ */
297
+ founderKeyPair?: Ed25519KeyPair;
298
+ /**
299
+ * The public key derived from the strand row's SHARED `MemberPrivateKey` (the read
300
+ * secret every joining party holds). When supplied for a closed strand, a
301
+ * `Strand.Member` or `Strand.Manager` row holding it refuses the bootstrap with
302
+ * {@link PreSplitStrandIdentityError} (see {@link assertNotPreSplitStrand}): nothing
303
+ * seats that key since the per-party identity split, so its presence is exactly the
304
+ * fingerprint of a strand founded before it. Omitted, no such check runs (a caller that
305
+ * holds no shared key has nothing to compare against). Ignored for an open strand.
306
+ */
307
+ sharedMemberPublicKey?: string;
308
+ }
309
+
310
+ /**
311
+ * A closed strand founded BEFORE the per-party identity split: its founding
312
+ * `Strand.Member` (and, unless handed on, `Strand.Manager`) is the key derived from the
313
+ * shared `MemberPrivateKey`, which every joining party holds, so any member can sign as
314
+ * the founder. There is no repair — rewriting the membership cannot be trusted, since any
315
+ * joiner could already have admitted or revoked anyone, or could race the rewrite — so
316
+ * the strand must be recreated. Thrown by {@link assertNotPreSplitStrand} (a founder
317
+ * bootstrap, or membership-invite issuance); the formation layer maps it to a
318
+ * non-retryable rejection.
319
+ */
320
+ export class PreSplitStrandIdentityError extends Error {
321
+ constructor(readonly strandId: string) {
322
+ super(
323
+ `Closed strand ${strandId} was founded before per-party strand identity: its founding ` +
324
+ 'member key is derived from the shared MemberPrivateKey that every joining party holds, ' +
325
+ 'so any member can act as its founder. It cannot be repaired — recreate the strand ' +
326
+ '(unpublish it and found a new one).',
327
+ );
328
+ this.name = 'PreSplitStrandIdentityError';
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Count rows in a `Strand.*` table as seen by this database instance.
334
+ *
335
+ * The table name is a fixed literal supplied by this module (never user input),
336
+ * so the interpolation is not an injection surface — it just keeps the singleton
337
+ * insert-if-absent guards terse.
338
+ */
339
+ async function strandTableCount(db: Database, table: 'Header' | 'Member' | 'Manager'): Promise<number> {
340
+ for await (const row of db.eval(`select count(1) as Count from Strand.${table}`)) {
341
+ return (row.Count as number) ?? 0;
342
+ }
343
+ return 0;
344
+ }
345
+
346
+ /**
347
+ * Whether any `Strand.Revocation` row has ever retired a `Manager` seat — the
348
+ * exact condition that permanently closes the founding branch of
349
+ * `Manager.Authorized` (see the schema's bootstrap-branch comment). Unfiltered
350
+ * scan + JavaScript comparison, the module's scan-not-seek idiom (rationale on
351
+ * {@link scanMemberPeers}).
352
+ *
353
+ * NOTE: walks the whole append-only `Strand.Revocation` table when no `Manager`
354
+ * tombstone is present (it returns on the first one it finds). Cheap at strand
355
+ * scale; if membership churn ever makes that table large — the growth the
356
+ * `Revocation` schema comment already flags, tracked by `debt-strand-tombstone-reap`
357
+ * — this wants a stored "founding closed" marker rather than a scan per call.
358
+ *
359
+ * NOTE: on a strand that has never revoked anyone, `Strand.Revocation` has never been
360
+ * written, so this node does not hold its block, and the storage layer consults the
361
+ * block's cohort on every read of a block it does not hold — this scan and every
362
+ * `NotRevoked` insert check pay that per call. Not measured, and cheap while strand
363
+ * membership writes are rare; if they become frequent, file a singleton marker row in
364
+ * the strand schema the way the control schema does (`ControlDatabase.openRevocationLedger`).
365
+ */
366
+ async function strandHasManagerRevocation(db: Database): Promise<boolean> {
367
+ for await (const row of db.eval('select TableName from Strand.Revocation')) {
368
+ if (row.TableName === 'Manager') {
369
+ return true;
370
+ }
371
+ }
372
+ return false;
373
+ }
374
+
375
+ /**
376
+ * Insert the singleton `Strand.Header` if absent.
377
+ *
378
+ * Every `Header` column is NOT NULL (Quereus defaults unqualified columns to NOT
379
+ * NULL; the table declares no `null` columns), so all eight are supplied non-null.
380
+ * A missing `sApp.signature` (dev `requireSignedSchemas:false`) coalesces to `''`
381
+ * rather than inserting null. `Header` carries only `InsertOnly` + a singleton PK,
382
+ * so the insert needs no `with context`.
383
+ */
384
+ async function insertHeaderIfAbsent(db: Database, params: FounderBootstrapParams): Promise<void> {
385
+ if (await strandTableCount(db, 'Header') > 0) {
386
+ log('Header already present for strand %s; skipping', params.strandId);
387
+ return;
388
+ }
389
+ await db.exec(
390
+ `insert into Strand.Header
391
+ (Id, Type, sAppId, sAppVersion, sAppSchema, sAppSignature, Engine, EngineVersion)
392
+ values (?, ?, ?, ?, ?, ?, ?, ?)`,
393
+ [
394
+ params.strandId,
395
+ params.type,
396
+ params.sApp.id,
397
+ params.sApp.version,
398
+ params.sApp.schema,
399
+ params.sApp.signature ?? '',
400
+ STRAND_ENGINE,
401
+ STRAND_ENGINE_VERSION,
402
+ ],
403
+ );
404
+ log('Inserted Header for strand %s (type %s)', params.strandId, params.type);
405
+ }
406
+
407
+ /**
408
+ * Insert the founding `Strand.Member` if no member exists yet.
409
+ *
410
+ * The empty PRE-transaction member set (`count(1) from committed.Member = 0`)
411
+ * satisfies the bootstrap branch of `Member.Authorized`, so no signature is
412
+ * needed — the context fields are explicit nulls. The branch also caps the
413
+ * POST-image at one member, so it waives authorization for exactly this single
414
+ * seat, never for a batch. The row still mints a fresh StampId — the column is
415
+ * NOT NULL on every path; bootstrap only waives the SIGNATURE. Guarding on the
416
+ * count makes this idempotent: a re-run (the founder re-`addStrand`/
417
+ * `resumeStrand`) finds the member present and skips.
418
+ */
419
+ async function insertFounderMemberIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
420
+ if (await strandTableCount(db, 'Member') > 0) {
421
+ log('Member already present for strand %s; skipping founder Member', strandId);
422
+ return;
423
+ }
424
+ await db.exec(
425
+ `insert into Strand.Member (Key, StampId)
426
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
427
+ values (?, ?)`,
428
+ [memberKey, generateStrandStampId()],
429
+ );
430
+ log('Inserted founding Member for strand %s', strandId);
431
+ }
432
+
433
+ /**
434
+ * Insert the founding `Strand.Manager` if no manager exists yet.
435
+ *
436
+ * The empty-table state satisfies the bootstrap branch of `Manager.Authorized`, so
437
+ * no signature is needed (a fresh StampId is still minted — the column is NOT
438
+ * NULL). The founder is seated at `Generation = 0` — the root of
439
+ * the manager lineage; the bootstrap branch requires exactly that value, and every
440
+ * later manager is seated at a strictly greater generation (see {@link addManager}).
441
+ * Idempotent via the same count guard.
442
+ *
443
+ * ORDERING IS LOAD-BEARING, not merely convenient. The bootstrap branch is gated to
444
+ * the founding state and now reads `exists (select 1 from Member M where M.Key =
445
+ * new.MemberKey)` alongside `count(Manager) <= 1` and `count(Member) <= 1`, so the
446
+ * founding `Member` row MUST already exist when this commits — a Manager-first
447
+ * seeding path is rejected. It must also run AFTER the Header insert:
448
+ * `Manager.OnlyClosed` is a deferred (subquery) check evaluated at commit. The
449
+ * sequential auto-commit Header→Member→Manager order in
450
+ * {@link bootstrapFounderMembership} satisfies both.
451
+ *
452
+ * SEALED strands are skipped, not re-founded: a strand whose sole manager has
453
+ * {@link sealStrand}ed also has zero `Manager` rows, but its founding branch is
454
+ * closed for good (the schema's bootstrap branch requires no `Manager`-table
455
+ * `Revocation` row to exist). Attempting the founding insert here would be
456
+ * schema-rejected and turn every founder restart of a sealed strand into a
457
+ * loud failure — so this mirrors the schema's own gate and returns quietly.
458
+ * A crash BETWEEN the founding Member and Manager inserts leaves no Revocation
459
+ * rows at all, so that restart still completes the founding.
460
+ */
461
+ async function insertFounderManagerIfAbsent(db: Database, memberKey: string, strandId: string): Promise<void> {
462
+ if (await strandTableCount(db, 'Manager') > 0) {
463
+ log('Manager already present for strand %s; skipping founder Manager', strandId);
464
+ return;
465
+ }
466
+ if (await strandHasManagerRevocation(db)) {
467
+ log('Strand %s has retired a manager seat (sealed); skipping founder Manager', strandId);
468
+ return;
469
+ }
470
+ await db.exec(
471
+ `insert into Strand.Manager (MemberKey, Generation, StampId)
472
+ with context ManagerKey = null, Signature = null
473
+ values (?, 0, ?)`,
474
+ [memberKey, generateStrandStampId()],
475
+ );
476
+ log('Inserted founding Manager for strand %s', strandId);
477
+ }
478
+
479
+ /**
480
+ * Refuse a closed strand whose membership still holds the key derived from the shared
481
+ * `MemberPrivateKey` (see {@link FounderBootstrapParams.sharedMemberPublicKey}). `Member`
482
+ * is the primary probe — every manager is a member (`Manager.MemberExists`), so it also
483
+ * catches a pre-split founder who handed management to another key and resigned, or a
484
+ * sealed pre-split strand. `Manager` is probed too, for the partition case the schema
485
+ * notes on `MemberExists` (a Manager row converged without its Member row). Cannot
486
+ * misfire: nothing seats the shared-derived key since the split, and rows not yet loaded
487
+ * read as absent.
488
+ *
489
+ * @throws {PreSplitStrandIdentityError} when a `Strand.Member` or `Strand.Manager` row is
490
+ * the shared-derived key.
491
+ */
492
+ export async function assertNotPreSplitStrand(db: Database, strandId: string, sharedMemberPublicKey: string): Promise<void> {
493
+ if (await memberStampId(db, sharedMemberPublicKey) !== undefined
494
+ || await managerRow(db, sharedMemberPublicKey) !== undefined) {
495
+ throw new PreSplitStrandIdentityError(strandId);
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Founder-only one-time bootstrap of a strand's `Strand.*` membership/RBAC rows.
501
+ *
502
+ * Runs once at bring-up on the strand's FOUNDER (the party that provisioned and
503
+ * published the strand). A joiner never calls this — it receives these rows via
504
+ * Optimystic sync. Every write is insert-if-absent (guarded by a row count) so a
505
+ * restart / founder re-`addStrand` is a no-op and never double-inserts.
506
+ *
507
+ * Behavior by strand type:
508
+ * - **Open (`'o'`)**: insert `Header` only. `Member`/`Manager`/`Invite` are
509
+ * `OnlyClosed` and would trip that constraint — they are skipped entirely.
510
+ * - **Closed (`'c'`)**: insert `Header(Type='c')`, then the founding `Member`
511
+ * (`Key = founderKeyPair.publicKeyB64`), then the founding `Manager`
512
+ * (`MemberKey = founderKeyPair.publicKeyB64`). Insert order matters: the
513
+ * Header must commit before the deferred `OnlyClosed` checks on Member/Manager
514
+ * evaluate at commit.
515
+ *
516
+ * A closed strand with no `founderKeyPair` throws: a closed strand with no founding
517
+ * Manager could never admit anyone, so failing loudly here (which propagates out
518
+ * of `StrandDatabase.initialize()` and triggers the runtime's rollback) is correct.
519
+ *
520
+ * A closed strand whose membership holds `sharedMemberPublicKey` throws
521
+ * {@link PreSplitStrandIdentityError} before writing anything: the insert-if-absent
522
+ * guards would otherwise skip over its pre-split founding rows and leave the shared
523
+ * key seated.
524
+ *
525
+ * @param db - The strand's Quereus database (schema already applied).
526
+ * @param params - Strand id/type, the sApp config for the Header, and (closed only)
527
+ * the derived founder keypair and the shared-derived public key to refuse.
528
+ * @throws If `type === 'c'` and no `founderKeyPair` is supplied.
529
+ * @throws {PreSplitStrandIdentityError} If `type === 'c'` and a `Strand.Member` or
530
+ * `Strand.Manager` row is `sharedMemberPublicKey`.
531
+ */
532
+ export async function bootstrapFounderMembership(db: Database, params: FounderBootstrapParams): Promise<void> {
533
+ const { strandId, type, founderKeyPair, sharedMemberPublicKey } = params;
534
+ log('Founder bootstrap for strand %s (type %s)', strandId, type);
535
+
536
+ // Validate BEFORE writing anything: a closed strand with no founder key must
537
+ // fail without leaving a closed `Header` that has no founding Member/Manager
538
+ // (such a strand could never admit anyone).
539
+ if (type === 'c' && !founderKeyPair) {
540
+ throw new Error(
541
+ `Cannot bootstrap closed strand ${strandId}: no founder key pair derived from this party's ` +
542
+ 'StrandPartyKey. A closed strand needs a founding Member + Manager or it can never admit members.',
543
+ );
544
+ }
545
+ if (type === 'c' && sharedMemberPublicKey !== undefined) {
546
+ await assertNotPreSplitStrand(db, strandId, sharedMemberPublicKey);
547
+ }
548
+
549
+ // Header is written for every strand; Member/Manager are closed-only.
550
+ await insertHeaderIfAbsent(db, params);
551
+
552
+ if (type === 'o') {
553
+ // Open strand: Member/Manager/Invite are OnlyClosed — nothing else to write.
554
+ return;
555
+ }
556
+
557
+ const memberKey = founderKeyPair!.publicKeyB64;
558
+ await insertFounderMemberIfAbsent(db, memberKey, strandId);
559
+ await insertFounderManagerIfAbsent(db, memberKey, strandId);
560
+ log('Founder bootstrap complete for closed strand %s', strandId);
561
+ }
562
+
563
+ // ── Invite issuance + consumption (the per-strand join handshake) ─────────────
564
+
565
+ /** Parameters for {@link issueInvite}. */
566
+ export interface IssueInviteParams {
567
+ /**
568
+ * The issuing manager's strand keypair. Its `publicKeyB64` must already be a
569
+ * `Strand.Manager` row (the `InviteValid` constraint rejects a non-manager).
570
+ */
571
+ managerKeyPair: Ed25519KeyPair;
572
+ /**
573
+ * Optional invite expiry as epoch milliseconds. When set, it is canonicalised
574
+ * via {@link canonicalDatetime} so the signed payload segment byte-matches the
575
+ * `datetime`-coerced `Invite.Expiration` the deferred CHECK sees. When omitted,
576
+ * the invite never expires and the signed segment is `''` (matching the schema's
577
+ * `coalesce(new.Expiration, '')`).
578
+ */
579
+ expiration?: number;
580
+ }
581
+
582
+ /** The minted invite: a public key (the `Invite.Key`) plus its private seed. */
583
+ export interface IssuedInvite {
584
+ /** The invite ed25519 PUBLIC key (base64url) — the `Invite.Key` primary key. */
585
+ inviteKey: string;
586
+ /**
587
+ * The invite ed25519 PRIVATE seed (base64url). Handed out-of-band to the
588
+ * invitee; whoever holds it can {@link consumeInvite} exactly once. NEVER
589
+ * persisted in the strand — only the public key is.
590
+ */
591
+ invitePrivateKey: string;
592
+ }
593
+
594
+ /**
595
+ * Issue a single-use invitation to join a closed strand.
596
+ *
597
+ * Generates a fresh invite ed25519 keypair (the public key becomes `Invite.Key`),
598
+ * builds the constraint's payload (`Key || '|' || coalesce(Expiration, '')`), and
599
+ * signs it TWICE: with the manager private key (→ `ManagerSignature`, proving
600
+ * a manager issued it) and with the invite private key (→ `InviteSignature`,
601
+ * proving the issuer actually holds the invite secret). Both signatures plus the
602
+ * manager public key are bound as constraint context for the `Invite` insert.
603
+ *
604
+ * The returned `invitePrivateKey` is the only secret the invitee needs to redeem
605
+ * the invite — it is NOT stored in the strand (only `Invite.Key`, the public half,
606
+ * is). Single-use is enforced at consumption time: `ConsumedInvite`'s primary key
607
+ * is `InviteKey`, so a given invite can be consumed at most once.
608
+ *
609
+ * @param db - The closed strand's database (founder already bootstrapped).
610
+ * @param params - The issuing manager keypair and optional expiry.
611
+ * @returns The invite public key and the out-of-band private seed.
612
+ */
613
+ export async function issueInvite(db: Database, params: IssueInviteParams): Promise<IssuedInvite> {
614
+ const { managerKeyPair, expiration } = params;
615
+
616
+ const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
617
+ const inviteKey = getPublicKey(invitePrivateKey, 'ed25519', 'base64url', 'base64url') as string;
618
+
619
+ // Canonicalise the expiry to the engine's stored `datetime` form so the signed
620
+ // segment matches `new.Expiration` post-coercion; null expiry signs as ''.
621
+ const expirationSegment = expiration == null ? '' : await canonicalDatetime(db, expiration);
622
+ const expirationColumn = expiration == null ? null : expirationSegment;
623
+
624
+ const payload = `${inviteKey}|${expirationSegment}`;
625
+ const managerSignature = signStrandPayload(payload, managerKeyPair.privateKeyB64);
626
+ const inviteSignature = signStrandPayload(payload, invitePrivateKey);
627
+
628
+ await db.exec(
629
+ `insert into Strand.Invite (Key, Expiration)
630
+ with context ManagerKey = ?, ManagerSignature = ?, InviteSignature = ?
631
+ values (?, ?)`,
632
+ [managerKeyPair.publicKeyB64, managerSignature, inviteSignature, inviteKey, expirationColumn],
633
+ );
634
+ log('Issued invite %s (expires=%s)', inviteKey, expirationColumn ?? 'never');
635
+
636
+ return { inviteKey, invitePrivateKey };
637
+ }
638
+
639
+ /** Parameters for {@link consumeInvite}. */
640
+ export interface ConsumeInviteParams {
641
+ /** The `Invite.Key` (invite PUBLIC key, base64url) being redeemed. */
642
+ inviteKey: string;
643
+ /** The matching invite PRIVATE seed (base64url) received out-of-band. */
644
+ invitePrivateKey: string;
645
+ /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
646
+ memberKey: string;
647
+ /**
648
+ * The instant (epoch ms) to compare against the invite's `Expiration` for the
649
+ * `NotExpired` gate. Defaults to `Date.now()`; tests pin it so the comparison is
650
+ * deterministic. Mirrors the control-layer `redeemInvitation`'s `nowMs` convention.
651
+ */
652
+ nowMs?: number;
653
+ }
654
+
655
+ /**
656
+ * Redeem an invite to admit a new `Member`, atomically.
657
+ *
658
+ * `Member.Authorized`'s invite branch needs a `ConsumedInvite` row, while
659
+ * `ConsumedInvite`'s `MemberExists` needs the `Member` row — a circular
660
+ * dependency. Both are deferred (subquery-bearing) checks that evaluate
661
+ * at COMMIT, so inserting `Member` then `ConsumedInvite` inside one explicit
662
+ * transaction lets both rows exist when the deferred checks fire. This mirrors
663
+ * `ControlDatabase.redeemInvitation` (Strand + FormationUsage in one txn).
664
+ *
665
+ * The `ConsumedInvite` insert carries an `InviteSignature` over
666
+ * `InviteKey || '|' || MemberKey`, proving the consumer holds the invite private
667
+ * key (the on-engine `ValidUsage` gate). The `Member` insert needs no member
668
+ * signature — its admission is the existence of the matching `ConsumedInvite` —
669
+ * but still mints a fresh StampId (NOT NULL on every path; a revoked-then-
670
+ * re-invited member is a fresh incarnation under a fresh stamp).
671
+ *
672
+ * Single-use: `ConsumedInvite`'s primary key is `InviteKey`, so a second consume
673
+ * of the same invite is rejected by the PK (a distinct layer from the control
674
+ * network's `FormationUsage` single-use, which gates strand FORMATION, not the
675
+ * per-strand member join enforced here).
676
+ *
677
+ * Expiry: the `ConsumedInvite.NotExpired` deferred check rejects redeeming an invite
678
+ * whose `Expiration` is at or before `context.Now`. `Now` is supplied here as a
679
+ * canonical-datetime string from `canonicalDatetime(db, nowMs)` — the SAME transform
680
+ * `issueInvite` uses to store `Invite.Expiration` — so both sides of the schema's
681
+ * `I.Expiration > context.Now` comparison are byte-identical canonical strings and
682
+ * the lexical `>` orders chronologically at any granularity. (This intentionally
683
+ * diverges from the control layer, which passes `Now` as a JS ISO string; Quereus
684
+ * does not coerce context params, so an ISO `Now` would be compared lexically against
685
+ * the canonical, T-separated `Expiration` and could mis-order near-same-instant
686
+ * timestamps (due to a trailing `.000Z` suffix). The control tests only use far-future/far-past expiries, so that latent
687
+ * skew never bites there — the strand layer avoids it outright.) A null `Expiration`
688
+ * never expires. Like `ValidUsage`, `NotExpired` defers to commit, so an expired
689
+ * invite rolls back the whole txn — neither the `Member` nor the `ConsumedInvite`
690
+ * row survives.
691
+ *
692
+ * @param db - The closed strand's database.
693
+ * @param params - The invite key/secret, the joining member's public key, and an
694
+ * optional `nowMs` instant for the expiry comparison (default `Date.now()`).
695
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
696
+ * @throws If any constraint rejects (bad signature, missing invite, or an expired
697
+ * invite); the whole transaction rolls back (neither the `Member` nor the
698
+ * `ConsumedInvite` row survives).
699
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
700
+ * transaction is open; nothing was written.
701
+ */
702
+ export async function consumeInvite(
703
+ db: Database,
704
+ params: ConsumeInviteParams,
705
+ options?: StrandWriteOptions,
706
+ ): Promise<void> {
707
+ const { inviteKey, memberKey } = params;
708
+ // Member first — admitted by the deferred invite branch (the matching ConsumedInvite
709
+ // after it), so no manager signature is supplied.
710
+ const member: StrandStatements = {
711
+ sql: `insert into Strand.Member (Key, StampId)
712
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = null
713
+ values (:memberKey, :memberStampId);`,
714
+ params: { memberKey, memberStampId: generateStrandStampId() },
715
+ };
716
+ await execStrandTransaction(db, combineStatements(member, await consumedInviteStatement(db, params)), options);
717
+ log('Consumed invite %s -> member %s', inviteKey, memberKey);
718
+ }
719
+
720
+ /**
721
+ * The `Strand.ConsumedInvite` marker row insert — the possession-plus-freshness proof
722
+ * shared by {@link consumeInvite} (batched after its `Member` insert) and
723
+ * {@link burnInvite} (alone, against an already-seated member).
724
+ *
725
+ * The invite signature covers `InviteKey || '|' || MemberKey` (the `ValidUsage` gate),
726
+ * and "now" is canonicalised the same way {@link issueInvite} canonicalises
727
+ * `Expiration`, so the schema's `I.Expiration > context.Now` compares like-for-like
728
+ * canonical strings. Plain runtime Date.now() — the tess Workflow restriction is on
729
+ * scripts, not libs.
730
+ */
731
+ async function consumedInviteStatement(db: Database, params: ConsumeInviteParams): Promise<StrandStatements> {
732
+ const { inviteKey, invitePrivateKey, memberKey, nowMs } = params;
733
+ return {
734
+ sql: `insert into Strand.ConsumedInvite (InviteKey, MemberKey)
735
+ with context InviteSignature = :consumedSignature, Now = :consumedNow
736
+ values (:consumedInviteKey, :consumedMemberKey);`,
737
+ params: {
738
+ consumedSignature: signStrandPayload(`${inviteKey}|${memberKey}`, invitePrivateKey),
739
+ consumedNow: await canonicalDatetime(db, nowMs ?? Date.now()),
740
+ consumedInviteKey: inviteKey,
741
+ consumedMemberKey: memberKey,
742
+ },
743
+ };
744
+ }
745
+
746
+ /**
747
+ * Burn an invitation against an ALREADY-SEATED member: insert the
748
+ * `Strand.ConsumedInvite` row alone, naming the existing `Member.Key`, so the bearer
749
+ * credential can never be spent by anyone else.
750
+ *
751
+ * The already-member arm of the bring-up membership reconciler
752
+ * (`strand-membership-reconciler.ts`): a machine that finds its party's `Member` row
753
+ * already present (a sibling machine redeemed the invitation's twin first, or a
754
+ * manager admitted the party directly) may still hold an UNSPENT formation-delivered
755
+ * invitation — a bearer credential whoever presents can join with. Every
756
+ * `ConsumedInvite` constraint is satisfiable without a same-transaction `Member`
757
+ * insert (`MemberExists` reads the live table), so this is one statement whose
758
+ * deferred checks fire at its own commit. It seats nobody now or later:
759
+ * `Member.Authorized`'s invite branch requires a same-transaction FRESH consumption,
760
+ * so the row it leaves is exactly as inert as any other spent invitation's.
761
+ *
762
+ * @param db - The closed strand's database (the member already exists).
763
+ * @param params - The invite key/secret and the EXISTING member's public key, plus the
764
+ * optional `nowMs` instant for the expiry gate (default `Date.now()`).
765
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
766
+ * @throws If any constraint rejects — already consumed (the `InviteKey` primary key),
767
+ * cancelled, expired, or a sealed strand — or the cohort refuses the write. The
768
+ * reconciler routes these through the same classifier as a `consumeInvite` rejection:
769
+ * a dead invitation is dropped, a refused write keeps it staged for a retry.
770
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
771
+ * transaction is open; nothing was tried, so the invitation is exactly as live as before.
772
+ */
773
+ export async function burnInvite(
774
+ db: Database,
775
+ params: ConsumeInviteParams,
776
+ options?: StrandWriteOptions,
777
+ ): Promise<void> {
778
+ await execStrandTransaction(db, await consumedInviteStatement(db, params), options);
779
+ log('Burned invite %s against existing member %s', params.inviteKey, params.memberKey);
780
+ }
781
+
782
+ /**
783
+ * Every value of `InviteKey` in one of the two invite-marker tables, as a `Set`.
784
+ *
785
+ * Unfiltered scan + JavaScript comparison, deliberately: `ConsumedInvite` and
786
+ * `CancelledInvite` each have the single column `InviteKey` as their primary key, so
787
+ * ANY where-equality on it is a FULL-PK predicate — which the optimystic virtual-table
788
+ * module serves via a single-key point lookup that can MISS on a networked strand (the
789
+ * argument {@link scanMemberPeers} makes in full). Reading the whole column and
790
+ * comparing in JS depends only on the scan returning a SUPERSET of the live rows.
791
+ *
792
+ * The table name is a fixed literal supplied by this module (never user input), so the
793
+ * interpolation is not an injection surface — same as {@link strandTableCount}.
794
+ *
795
+ * NOTE: every caller pays a WHOLE-column read — {@link cancelInvite}'s idempotence guard
796
+ * reads all cancelled keys to answer one membership question, and
797
+ * {@link listOutstandingInvites} reads both marker tables per call. Fine at strand scale
798
+ * (a handful of invitations); if invite churn ever gets large this wants a real filtered
799
+ * read, which in turn needs the networked point-lookup gap closed
800
+ * (`debt-composite-pk-point-lookup-unreliable-untracked`).
801
+ */
802
+ async function scanInviteKeys(db: Database, table: 'ConsumedInvite' | 'CancelledInvite'): Promise<Set<string>> {
803
+ const keys = new Set<string>();
804
+ for await (const row of db.eval(`select InviteKey from Strand.${table}`)) {
805
+ keys.add(row.InviteKey as string);
806
+ }
807
+ return keys;
808
+ }
809
+
810
+ /** Parameters for {@link cancelInvite}. */
811
+ export interface CancelInviteParams {
812
+ /**
813
+ * The cancelling manager's strand keypair. Its `publicKeyB64` must be a
814
+ * PRE-EXISTING `Strand.Manager` row — `CancelledInvite.Authorized` reads
815
+ * `committed.Manager`, so a manager seated in the SAME transaction cannot cancel.
816
+ */
817
+ managerKeyPair: Ed25519KeyPair;
818
+ /** The `Invite.Key` to kill. */
819
+ inviteKey: string;
820
+ }
821
+
822
+ /**
823
+ * Cancel an outstanding invitation, permanently: it may never be consumed.
824
+ *
825
+ * Files a `Strand.CancelledInvite` tombstone carrying a manager signature over
826
+ * `digest('Strand.CancelledInvite', 'cancel', InviteKey)`. The digest binds the exact
827
+ * invite key, so an approval minted for one invitation cannot cancel another.
828
+ *
829
+ * One row, one statement — no transaction needed. `CancelledInvite.Authorized` is a
830
+ * deferred (subquery-bearing) check, so it fires at this statement's own auto-commit.
831
+ *
832
+ * Insert-if-absent, matching {@link registerMemberPeer}'s and {@link revokeMember}'s
833
+ * restart-safe shape: a repeat cancellation logs and returns rather than throwing on the
834
+ * primary key. (Check-then-write is not atomic; the primary key is the real backstop —
835
+ * the guard is for the sequential repeat/restart path.)
836
+ *
837
+ * Cancellation is what makes removal a re-entry gate: it is the `ConsumedInvite` insert
838
+ * that `NotCancelled` blocks, and `Member.Authorized`'s invite branch needs a
839
+ * same-transaction FRESH `ConsumedInvite` row, so a cancelled invitation rolls the whole
840
+ * join back. Un-cancelling is impossible (`CancelledInvite.Immutable`) — to re-invite a
841
+ * party, {@link issueInvite} a fresh invitation.
842
+ *
843
+ * Enumerate what there is to cancel with {@link listOutstandingInvites}: an invitation
844
+ * names no invitee, so the strand cannot tell a manager WHICH invitations a departing
845
+ * member holds. Invitee binding is tracked as `feat-strand-invitee-bound-invites`.
846
+ *
847
+ * @param db - The closed strand's database.
848
+ * @param params - The cancelling manager's keypair and the invite key to kill.
849
+ * @throws If `CancelledInvite.Authorized` rejects (a non-manager, a manager seated in
850
+ * this transaction, or an approval minted for a different invite key) or `OnlyClosed`
851
+ * rejects (an open strand); the insert rolls back, leaving no tombstone.
852
+ */
853
+ export async function cancelInvite(db: Database, params: CancelInviteParams): Promise<void> {
854
+ const { managerKeyPair, inviteKey } = params;
855
+
856
+ if ((await scanInviteKeys(db, 'CancelledInvite')).has(inviteKey)) {
857
+ log('Invite %s already cancelled; skipping', inviteKey);
858
+ return;
859
+ }
860
+
861
+ const signature = signStrandApproval(
862
+ ['Strand.CancelledInvite', 'cancel', inviteKey],
863
+ managerKeyPair.privateKeyB64,
864
+ );
865
+ await db.exec(
866
+ `insert into Strand.CancelledInvite (InviteKey)
867
+ with context ManagerKey = ?, ManagerSignature = ?
868
+ values (?)`,
869
+ [managerKeyPair.publicKeyB64, signature, inviteKey],
870
+ );
871
+ log('Cancelled invite %s by manager %s', inviteKey, managerKeyPair.publicKeyB64);
872
+ }
873
+
874
+ /** One redeemable invitation, as reported by {@link listOutstandingInvites}. */
875
+ export interface OutstandingInvite {
876
+ /** The `Invite.Key` (invite public key, base64url). */
877
+ inviteKey: string;
878
+ /**
879
+ * The expiry as the engine's canonical `datetime` string
880
+ * (`YYYY-MM-DDTHH:MM:SS[.frac]`, no zone suffix — see {@link canonicalDatetime}), or
881
+ * `null` for never-expires.
882
+ *
883
+ * Deliberately the STORED form, not the epoch-ms {@link IssueInviteParams.expiration}
884
+ * takes: it is what compares like-for-like against another canonicalised instant, and
885
+ * what the on-engine `NotExpired` gate sees. It is NOT a JS-parseable ISO instant —
886
+ * pass it through the engine (or `canonicalDatetime`) rather than `Date.parse`.
887
+ */
888
+ expiration: string | null;
889
+ }
890
+
891
+ /**
892
+ * The invitations that are still redeemable: neither consumed, nor cancelled, nor
893
+ * expired at `nowMs`.
894
+ *
895
+ * `Strand.Invite` is insert-only and carries no state column, so "outstanding" is not a
896
+ * property of the row — it is the `Invite` set minus the `ConsumedInvite` keys, minus
897
+ * the `CancelledInvite` keys, minus anything whose `Expiration` has passed. All three
898
+ * exclusions mirror gates a consume would hit anyway (`ConsumedInvite`'s primary key,
899
+ * `NotCancelled`, `NotExpired`).
900
+ *
901
+ * Reads via unfiltered scans and compares in JavaScript — see {@link scanInviteKeys} for
902
+ * why a full-PK where-equality is not reliable on a networked strand.
903
+ *
904
+ * Expiry is compared as `expiration > canonicalDatetime(nowMs)`: the same transform
905
+ * {@link issueInvite} uses to STORE `Invite.Expiration`, so the string comparison orders
906
+ * chronologically, and the same strict `>` the on-engine `NotExpired` gate uses (expiry
907
+ * is exclusive — an invitation is dead at its expiry instant, not after it).
908
+ *
909
+ * This is the manager's enumeration side of {@link cancelInvite}, and the "door is open"
910
+ * pre-flight behind `StrandMemberVerifier.isAuthorizedToJoin`.
911
+ *
912
+ * @param db - The closed strand's database.
913
+ * @param nowMs - The instant (epoch ms) to compare expiries against; defaults to
914
+ * `Date.now()`. Tests pin it so the comparison is deterministic, mirroring
915
+ * {@link consumeInvite}'s `nowMs` convention.
916
+ * @returns The redeemable invitations, in NO guaranteed order (the scans promise only a
917
+ * superset of the live rows, not a stable sequence — sort by `expiration` if order
918
+ * matters); empty if none.
919
+ */
920
+ export async function listOutstandingInvites(db: Database, nowMs?: number): Promise<OutstandingInvite[]> {
921
+ // Plain runtime Date.now() — the tess Workflow restriction is on scripts, not libs.
922
+ const nowCanonical = await canonicalDatetime(db, nowMs ?? Date.now());
923
+ const consumed = await scanInviteKeys(db, 'ConsumedInvite');
924
+ const cancelled = await scanInviteKeys(db, 'CancelledInvite');
925
+
926
+ const outstanding: OutstandingInvite[] = [];
927
+ for await (const row of db.eval('select Key, Expiration from Strand.Invite')) {
928
+ const inviteKey = row.Key as string;
929
+ const expiration = (row.Expiration as string | null) ?? null;
930
+ if (consumed.has(inviteKey) || cancelled.has(inviteKey)) {
931
+ continue;
932
+ }
933
+ if (expiration != null && expiration <= nowCanonical) {
934
+ continue;
935
+ }
936
+ outstanding.push({ inviteKey, expiration });
937
+ }
938
+ return outstanding;
939
+ }
940
+
941
+ // ── Member admission + removal (manager-admit, revoke, leave) ─────────────────
942
+
943
+ /**
944
+ * The live StampId of the `Strand.Member` row keyed by `memberKey`, or
945
+ * `undefined` if no such row is visible.
946
+ *
947
+ * `Member`'s primary key is the single `Key` column, so ANY where-equality on it
948
+ * is a full-PK predicate — which the optimystic virtual-table module serves via a
949
+ * single-key point lookup that can MISS on a networked strand (the same failure
950
+ * mode {@link scanMemberPeers} documents). So this reads via an UNFILTERED scan
951
+ * and compares the key in JavaScript: correctness depends only on the scan
952
+ * returning a superset of the live rows — the weakest possible assumption about
953
+ * the storage layer.
954
+ */
955
+ async function memberStampId(db: Database, memberKey: string): Promise<string | undefined> {
956
+ for await (const row of db.eval('select Key, StampId from Strand.Member')) {
957
+ if (row.Key === memberKey) {
958
+ return row.StampId as string;
959
+ }
960
+ }
961
+ return undefined;
962
+ }
963
+
964
+ /**
965
+ * Whether `memberKey` holds a live `Strand.Member` row, as seen by THIS database
966
+ * instance — the membership probe behind the bring-up reconciler
967
+ * (`strand-membership-reconciler.ts`). Reads via {@link memberStampId}'s unfiltered
968
+ * scan + JavaScript compare (the {@link scanMemberPeers} argument for why a PK point
969
+ * lookup is not reliable on a networked strand). Local visibility only: `false` can
970
+ * mean "not replicated here yet", never a durable verdict about the strand.
971
+ */
972
+ export async function isStrandMember(db: Database, memberKey: string): Promise<boolean> {
973
+ return await memberStampId(db, memberKey) != null;
974
+ }
975
+
976
+ /** Parameters for {@link addMemberByManager}. */
977
+ export interface AddMemberByManagerParams {
978
+ /**
979
+ * The admitting manager's strand keypair. Its `publicKeyB64` must be a
980
+ * `Strand.Manager` row; it signs the add-tagged digest over the new member key
981
+ * and its fresh stamp.
982
+ */
983
+ managerKeyPair: Ed25519KeyPair;
984
+ /** The joining member's ed25519 PUBLIC key (base64url) — the new `Member.Key`. */
985
+ memberKey: string;
986
+ }
987
+
988
+ /**
989
+ * Admit a `Member` directly by manager signature — the sibling of the invite
990
+ * path on `Member.Authorized`'s direct-manager branch.
991
+ *
992
+ * The constraint verifies the add-tagged digest
993
+ * `digest('Strand.Member', 'add', new.Key, new.StampId)` against a pre-existing
994
+ * (`committed.Manager`) row matching `context.ManagerKey`. The stamp is minted
995
+ * fresh here and bound inside the signed digest, so the approval seats exactly
996
+ * this incarnation: once the member is removed (retiring the stamp), the captured
997
+ * approval can never re-seat it. No `ConsumedInvite` is involved: this is the
998
+ * path a manager uses to seat a member it already trusts (e.g. a manager-side
999
+ * enrollment that admits a party already authorised out-of-band).
1000
+ *
1001
+ * @param db - The closed strand's database.
1002
+ * @param params - The admitting manager keypair and the new member key.
1003
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1004
+ * @throws If `Member.Authorized` rejects (e.g. a non-manager key); the insert
1005
+ * rolls back, leaving no `Member` row.
1006
+ */
1007
+ export async function addMemberByManager(
1008
+ db: Database,
1009
+ params: AddMemberByManagerParams,
1010
+ options?: StrandWriteOptions,
1011
+ ): Promise<void> {
1012
+ const { managerKeyPair, memberKey } = params;
1013
+ await execStrandTransaction(db, memberAddByManagerStatement(params), options);
1014
+ log('Admitted member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
1015
+ }
1016
+
1017
+ /** The manager-signed `Strand.Member` insert behind {@link addMemberByManager}, under a fresh stamp. */
1018
+ function memberAddByManagerStatement(params: AddMemberByManagerParams): StrandStatements {
1019
+ const { managerKeyPair, memberKey } = params;
1020
+ const stampId = generateStrandStampId();
1021
+ return {
1022
+ sql: `insert into Strand.Member (Key, StampId)
1023
+ with context ManagerKey = :memberAdmitterKey, ManagerSignature = :memberAdmitterSignature, MemberSignature = null
1024
+ values (:memberKey, :memberStampId);`,
1025
+ params: {
1026
+ memberAdmitterKey: managerKeyPair.publicKeyB64,
1027
+ memberAdmitterSignature: signStrandApproval(['Strand.Member', 'add', memberKey, stampId], managerKeyPair.privateKeyB64),
1028
+ memberKey,
1029
+ memberStampId: stampId,
1030
+ },
1031
+ };
1032
+ }
1033
+
1034
+ /** Parameters for {@link revokeMember}. */
1035
+ export interface RevokeMemberParams {
1036
+ /**
1037
+ * The revoking manager's strand keypair. Its `publicKeyB64` must be a
1038
+ * PRE-EXISTING `Strand.Manager` row (the manager-removal branch of
1039
+ * `Member.Authorized` reads `committed.Manager` — a manager seated in the same
1040
+ * transaction cannot authorize); it signs the remove-tagged digest over the
1041
+ * target row's key and live stamp, and files the accompanying tombstone.
1042
+ */
1043
+ managerKeyPair: Ed25519KeyPair;
1044
+ /** The `Member.Key` row to delete. */
1045
+ memberKey: string;
1046
+ }
1047
+
1048
+ /**
1049
+ * Revoke a `Member` on the signature of an existing manager.
1050
+ *
1051
+ * Reads the target row's live StampId first (a quiet no-op if the row is already
1052
+ * absent — a repeated or restarted revocation should not throw), then runs ONE
1053
+ * transaction pairing the delete with its `Strand.Revocation` tombstone: the
1054
+ * schema's `Member.RevocationRecorded` requires the tombstone in the same
1055
+ * transaction, and the tombstone is what permanently retires the stamp so the
1056
+ * captured admission approval can never re-seat this incarnation.
1057
+ *
1058
+ * The manager-removal branch of `Member.Authorized` verifies the remove-tagged
1059
+ * digest `digest('Strand.Member', 'remove', old.Key, old.StampId)` against a
1060
+ * `committed.Manager` row matching `context.ManagerKey` — distinct from the
1061
+ * add-tagged admission payload, and bound to the live stamp, so neither a
1062
+ * captured admission nor a captured PRIOR removal (whose stamp differs) replays.
1063
+ *
1064
+ * A revoked member cannot re-admit itself off the invite it ALREADY SPENT: that
1065
+ * `ConsumedInvite` row is stale, and the invite branch requires a
1066
+ * same-transaction FRESH consumption. Re-admission normally takes a fresh
1067
+ * manager action ({@link addMemberByManager} or a new invite).
1068
+ *
1069
+ * An UNSPENT invite the revoked party holds is killed EXPLICITLY, by the manager
1070
+ * calling {@link cancelInvite} on it (enumerate the candidates with
1071
+ * {@link listOutstandingInvites}); a cancelled invitation can never be consumed,
1072
+ * and cancellation is permanent. This removal does NOT cascade into cancellation:
1073
+ * an invitation names no invitee, so there is nothing to match the removed member
1074
+ * against — the strand cannot tell which invitations were meant for it, or whether
1075
+ * it holds any. Binding an invitation to its invitee is tracked as
1076
+ * `feat-strand-invitee-bound-invites`.
1077
+ *
1078
+ * A manager must resign its `Manager` row before (or in the same transaction
1079
+ * as) losing membership — `Member.NotAManager` rejects un-membering a key that
1080
+ * still holds a Manager row. And the strand never drops to zero members
1081
+ * (`Member.MinOneMember`, a local-count floor with the cross-node caveat its
1082
+ * schema NOTE states).
1083
+ *
1084
+ * Callers running inside a cadre runtime should follow this write with
1085
+ * `CadreNode.refreshRevocationEnforcement(strandId)`. The removed party's
1086
+ * machines are refused at the network layer by the strand's revoked-peer gate
1087
+ * (`strand-revocation-enforcer.ts`), which polls for membership changes because
1088
+ * this bare `Database` handle raises no event the runtime can hook — so without
1089
+ * that call the cut lands up to one poll interval (default 30 s) later.
1090
+ *
1091
+ * @param db - The closed strand's database.
1092
+ * @param params - The revoking manager keypair and the target member key.
1093
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1094
+ * @throws If `Member.Authorized` rejects (a non-manager or same-transaction
1095
+ * signer), `Member.NotAManager` rejects (the target still holds a Manager
1096
+ * row), or `Member.MinOneMember` rejects (the removal would empty the member
1097
+ * set); the whole delete+tombstone transaction rolls back.
1098
+ */
1099
+ export async function revokeMember(
1100
+ db: Database,
1101
+ params: RevokeMemberParams,
1102
+ options?: StrandWriteOptions,
1103
+ ): Promise<void> {
1104
+ const { managerKeyPair, memberKey } = params;
1105
+ const stampId = await memberStampId(db, memberKey);
1106
+ if (stampId == null) {
1107
+ log('Member %s already absent; skipping revoke', memberKey);
1108
+ return;
1109
+ }
1110
+ const removal: StrandStatements = {
1111
+ sql: `delete from Strand.Member
1112
+ with context ManagerKey = :memberRemoverKey, ManagerSignature = :memberRemoverSignature, MemberSignature = null
1113
+ where Key = :memberKey;`,
1114
+ params: {
1115
+ memberRemoverKey: managerKeyPair.publicKeyB64,
1116
+ memberRemoverSignature: signStrandApproval(['Strand.Member', 'remove', memberKey, stampId], managerKeyPair.privateKeyB64),
1117
+ memberKey,
1118
+ },
1119
+ };
1120
+ await execStrandTransaction(
1121
+ db,
1122
+ combineStatements(removal, revocationStatement('Member', stampId, managerKeyPair)),
1123
+ options,
1124
+ );
1125
+ log('Revoked member %s by manager %s', memberKey, managerKeyPair.publicKeyB64);
1126
+ }
1127
+
1128
+ /** Parameters for {@link leaveStrand}. */
1129
+ export interface LeaveStrandParams {
1130
+ /**
1131
+ * The departing member's OWN strand keypair. Its `publicKeyB64` is the
1132
+ * `Member.Key` row being deleted; it self-signs the leave-tagged digest —
1133
+ * the self-departure branch verifies the signature against `old.Key` itself,
1134
+ * so only the departing key's holder can produce it — and files the
1135
+ * accompanying tombstone (it is still a committed member until this very
1136
+ * transaction removes it, which is what `Revocation.Authorized` checks). No
1137
+ * manager is involved.
1138
+ */
1139
+ memberKeyPair: Ed25519KeyPair;
1140
+ }
1141
+
1142
+ /**
1143
+ * A member leaves the strand by deleting its own `Member` row.
1144
+ *
1145
+ * The self-departure branch of `Member.Authorized` verifies
1146
+ * `digest('Strand.Member', 'leave', old.Key, old.StampId)` against `old.Key`
1147
+ * itself via `context.MemberSignature`. The `'leave'` tag (vs the manager
1148
+ * branch's `'remove'`) is belt-and-braces only — the two branches verify against
1149
+ * different keys; the anti-replay fix is the stamp binding. Like
1150
+ * {@link revokeMember}, the live stamp is read first (absent row → quiet no-op)
1151
+ * and the delete pairs with its `Strand.Revocation` tombstone in one transaction.
1152
+ *
1153
+ * The same floors as {@link revokeMember} apply: a manager must resign first
1154
+ * (`NotAManager`) and the last member cannot leave (`MinOneMember`).
1155
+ *
1156
+ * Like {@link revokeMember}, a caller inside a cadre runtime should follow this
1157
+ * with `CadreNode.refreshRevocationEnforcement(strandId)` — here it is what
1158
+ * makes the departing node notice its own departure promptly (the gate emits
1159
+ * `strand:revoked` when it finds this node's own peer id revoked).
1160
+ *
1161
+ * @param db - The closed strand's database.
1162
+ * @param params - The departing member's own keypair.
1163
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1164
+ * @throws If `Member.Authorized`, `Member.NotAManager`, or `Member.MinOneMember`
1165
+ * rejects; the whole delete+tombstone transaction rolls back.
1166
+ */
1167
+ export async function leaveStrand(
1168
+ db: Database,
1169
+ params: LeaveStrandParams,
1170
+ options?: StrandWriteOptions,
1171
+ ): Promise<void> {
1172
+ const { memberKeyPair } = params;
1173
+ const memberKey = memberKeyPair.publicKeyB64;
1174
+ const stampId = await memberStampId(db, memberKey);
1175
+ if (stampId == null) {
1176
+ log('Member %s already absent; skipping leave', memberKey);
1177
+ return;
1178
+ }
1179
+ const departure: StrandStatements = {
1180
+ sql: `delete from Strand.Member
1181
+ with context ManagerKey = null, ManagerSignature = null, MemberSignature = :memberSignature
1182
+ where Key = :memberKey;`,
1183
+ params: {
1184
+ memberSignature: signStrandApproval(['Strand.Member', 'leave', memberKey, stampId], memberKeyPair.privateKeyB64),
1185
+ memberKey,
1186
+ },
1187
+ };
1188
+ await execStrandTransaction(
1189
+ db,
1190
+ combineStatements(departure, revocationStatement('Member', stampId, memberKeyPair)),
1191
+ options,
1192
+ );
1193
+ log('Member %s left the strand', memberKey);
1194
+ }
1195
+
1196
+ // ── MemberPeer registration (a member binds its own network nodes) ─────────────
1197
+
1198
+ /** Parameters for {@link registerMemberPeer}. */
1199
+ export interface RegisterMemberPeerParams {
1200
+ /**
1201
+ * The member's OWN strand keypair (`{ privateKeyB64, publicKeyB64 }`). Its
1202
+ * `publicKeyB64` becomes `MemberPeer.MemberKey` and it self-signs the binding —
1203
+ * `MemberPeer.Authorized` verifies the signature against `MemberKey` itself, so a
1204
+ * peer can only be registered by the very member it belongs to (no manager
1205
+ * involved). The founder passes its `strandMemberKeyPair`; an invited member
1206
+ * passes its own keypair. Typed as {@link Ed25519KeyPair} only for the shared
1207
+ * base64url keypair shape — no manager privilege is implied.
1208
+ */
1209
+ memberKeyPair: Ed25519KeyPair;
1210
+ /** The peer/node id (libp2p peer id string) to associate with the member. */
1211
+ peerId: string;
1212
+ }
1213
+
1214
+ /**
1215
+ * Register a network node (`PeerId`) as acting on behalf of a member.
1216
+ *
1217
+ * The member self-signs the add-tagged digest
1218
+ * `digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId)`
1219
+ * with its OWN key; the schema's `MemberPeer.Authorized` verifies that signature
1220
+ * against `new.MemberKey` — i.e. the member key itself — so only the member that
1221
+ * owns the key can register peers for it. The stamp is minted fresh here and
1222
+ * bound inside the digest, so a captured registration approval neither
1223
+ * re-registers a cleared binding (its stamp is retired) nor authorizes a removal
1224
+ * (wrong tag). A deferred `MemberExists` additionally requires the `Member` row
1225
+ * to already exist, so a peer for a non-member is rejected at commit.
1226
+ *
1227
+ * Insert-if-absent: a re-register on restart (or a redundant call) is a no-op. The
1228
+ * platform DOES reject a duplicate-PK insert, but a restart-safe re-register should
1229
+ * succeed quietly rather than throw, so the writer guards on existence instead of
1230
+ * catching. The guard ({@link memberPeerStampId}) scans the member's peers and compares
1231
+ * both key columns in JavaScript rather than seeking the composite primary key — see
1232
+ * {@link scanMemberPeers} for why a full-PK point lookup is not reliable on a networked
1233
+ * strand. A member may register multiple DISTINCT `PeerId`s (multi-device); each is its
1234
+ * own row (and its own stamp) under the same `MemberKey`.
1235
+ *
1236
+ * Peer DELETION is signature-checked too, not rejected outright: `Authorized` carries
1237
+ * distinct delete branches (self + manager), each over its own action-tagged digest
1238
+ * bound to the row's stamp. Both removal paths live in {@link removeMemberPeer}.
1239
+ * UPDATE is refused outright by `NoUpdate`: a re-binding is a remove plus a fresh
1240
+ * register (under a fresh stamp).
1241
+ *
1242
+ * @param db - The closed strand's database (the member already exists).
1243
+ * @param params - The member's own keypair and the peer id to bind.
1244
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1245
+ * @throws If `MemberPeer.Authorized`/`MemberExists` rejects (wrong signer, or no
1246
+ * matching `Member` row); the insert rolls back, leaving no `MemberPeer` row.
1247
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
1248
+ * transaction is open; nothing was written.
1249
+ */
1250
+ export async function registerMemberPeer(
1251
+ db: Database,
1252
+ params: RegisterMemberPeerParams,
1253
+ options?: StrandWriteOptions,
1254
+ ): Promise<void> {
1255
+ const { memberKeyPair, peerId } = params;
1256
+ const memberKey = memberKeyPair.publicKeyB64;
1257
+
1258
+ if (await memberPeerStampId(db, memberKey, peerId) != null) {
1259
+ log('MemberPeer (%s, %s) already present; skipping', memberKey, peerId);
1260
+ return;
1261
+ }
1262
+
1263
+ const stampId = generateStrandStampId();
1264
+ await execStrandTransaction(db, {
1265
+ sql: `insert into Strand.MemberPeer (MemberKey, PeerId, StampId)
1266
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1267
+ values (:peerMemberKey, :peerId, :peerStampId);`,
1268
+ params: {
1269
+ peerSignature: signStrandApproval(['Strand.MemberPeer', 'add', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
1270
+ peerMemberKey: memberKey,
1271
+ peerId,
1272
+ peerStampId: stampId,
1273
+ },
1274
+ }, options);
1275
+ log('Registered MemberPeer (%s, %s)', memberKey, peerId);
1276
+ }
1277
+
1278
+ /**
1279
+ * Yield every `(PeerId, StampId)` bound to `memberKey`, re-comparing the member key
1280
+ * in JavaScript.
1281
+ *
1282
+ * Deliberately does NOT filter on the full composite primary key. `MemberPeer`'s PK
1283
+ * is `(MemberKey, PeerId)`, so a `where MemberKey = ? and PeerId = ?` predicate puts
1284
+ * an equality on BOTH key columns — which the optimystic virtual-table module reports
1285
+ * as fully handled and serves via a single-key point lookup (one `find` descent), with
1286
+ * the SQL engine adding no filter of its own. On a networked strand that descent is not
1287
+ * reliable: a miss returns zero rows for a row that provably exists.
1288
+ *
1289
+ * Filtering on only the LEADING key column is a partial PK match, which the same module
1290
+ * explicitly declines to handle — it falls through to a table scan and the SQL engine
1291
+ * applies `MemberKey = ?` itself. No seek is involved, so no seek can miss. The member key
1292
+ * is then re-compared here in JavaScript, so correctness depends only on the scan returning
1293
+ * a SUPERSET of the matching rows — the weakest possible assumption about the storage layer.
1294
+ * The `where` clause only trims what crosses into JS — it is NOT pushed down (see the cost
1295
+ * note below) — so it is not a correctness dependency: a dropped or mis-applied predicate
1296
+ * cannot produce a row attributed to the wrong member.
1297
+ */
1298
+ async function* scanMemberPeers(db: Database, memberKey: string): AsyncGenerator<{ peerId: string; stampId: string }> {
1299
+ // NOTE: because the predicate is not pushed down, the storage layer walks the WHOLE
1300
+ // MemberPeer table (every member's rows) per call and the SQL engine filters. Fine at
1301
+ // strand scale; if MemberPeer ever grows large, the fix is a reliable composite-key
1302
+ // seek, not a bigger scan.
1303
+ // NOTE: if a secondary index on MemberPeer.MemberKey is ever added, this query stops
1304
+ // being a scan and becomes an index seek — re-introducing the seek dependency this
1305
+ // shape exists to remove.
1306
+ for await (const row of db.eval(
1307
+ 'select MemberKey, PeerId, StampId from Strand.MemberPeer where MemberKey = ?',
1308
+ [memberKey],
1309
+ )) {
1310
+ if (row.MemberKey === memberKey) {
1311
+ yield { peerId: row.PeerId as string, stampId: row.StampId as string };
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Every `PeerId` currently bound to `memberKey`, in storage order.
1318
+ *
1319
+ * The enumeration side of {@link removeMemberPeer}: a manager cleaning up after a
1320
+ * revocation knows only the departed member's key, never which devices it registered, so
1321
+ * cleanup is `listMemberPeers` then one `removeMemberPeer` per id. Reads only — the rows
1322
+ * it returns may name a `MemberKey` with no `Member` row (see the `MemberPeer` table NOTE
1323
+ * in the schema), so this is NOT a membership test.
1324
+ *
1325
+ * @param db - The closed strand's database.
1326
+ * @param memberKey - The `MemberPeer.MemberKey` to enumerate.
1327
+ * @returns The bound peer ids, empty if the member has none.
1328
+ */
1329
+ export async function listMemberPeers(db: Database, memberKey: string): Promise<string[]> {
1330
+ const peerIds: string[] = [];
1331
+ for await (const peer of scanMemberPeers(db, memberKey)) {
1332
+ peerIds.push(peer.peerId);
1333
+ }
1334
+ return peerIds;
1335
+ }
1336
+
1337
+ /**
1338
+ * The live StampId of the `MemberPeer` row for this `(MemberKey, PeerId)`, or
1339
+ * `undefined` if no such row is visible — doubling as the existence probe.
1340
+ *
1341
+ * NOTE: check-then-write is not atomic. Two nodes registering the same
1342
+ * (MemberKey, PeerId) concurrently can both observe "absent"; the primary key is the
1343
+ * real backstop. The existence half of this guard is for the sequential restart /
1344
+ * re-register path.
1345
+ */
1346
+ async function memberPeerStampId(db: Database, memberKey: string, peerId: string): Promise<string | undefined> {
1347
+ for await (const peer of scanMemberPeers(db, memberKey)) {
1348
+ if (peer.peerId === peerId) {
1349
+ return peer.stampId;
1350
+ }
1351
+ }
1352
+ return undefined;
1353
+ }
1354
+
1355
+ /** Parameters for the SELF branch of {@link removeMemberPeer}. */
1356
+ export interface RemoveOwnPeerParams {
1357
+ /**
1358
+ * The member's OWN strand keypair. Its `publicKeyB64` is the `MemberPeer.MemberKey`
1359
+ * of the row being deleted, and it signs the remove-tagged digest
1360
+ * `digest('Strand.MemberPeer', 'remove', MemberKey, PeerId, StampId)` — the self
1361
+ * branch of `MemberPeer.Authorized` verifies against the row's own member key, so
1362
+ * no OTHER member's row is reachable this way, and the tag + stamp binding keep a
1363
+ * captured registration approval from doubling as a removal (and vice versa).
1364
+ */
1365
+ memberKeyPair: Ed25519KeyPair;
1366
+ /** The `PeerId` of the binding to delete. */
1367
+ peerId: string;
1368
+ }
1369
+
1370
+ /** Parameters for the MANAGER branch of {@link removeMemberPeer}. */
1371
+ export interface RemoveMemberPeerByManagerParams {
1372
+ /**
1373
+ * The clearing manager's strand keypair. Its `publicKeyB64` must be a PRE-EXISTING
1374
+ * `Strand.Manager` row (the manager branch reads `committed.Manager`, so a manager
1375
+ * seated in the same transaction cannot authorize); it signs the
1376
+ * manager-remove-tagged digest over the exact row and its live stamp, and files
1377
+ * the accompanying tombstone.
1378
+ */
1379
+ managerKeyPair: Ed25519KeyPair;
1380
+ /** The `MemberPeer.MemberKey` whose binding is being cleared. */
1381
+ memberKey: string;
1382
+ /** The `PeerId` of the binding to delete. */
1383
+ peerId: string;
1384
+ }
1385
+
1386
+ /**
1387
+ * Either shape accepted by {@link removeMemberPeer}. A discriminated union rather than
1388
+ * one opaque keypair: the two branches sign DIFFERENT action tags (`'remove'` self vs
1389
+ * `'manager-remove'`), so each case carries exactly the fields its payload needs.
1390
+ */
1391
+ export type RemoveMemberPeerParams = RemoveOwnPeerParams | RemoveMemberPeerByManagerParams;
1392
+
1393
+ /**
1394
+ * Delete a `MemberPeer` binding — either the member clearing its own, or a manager
1395
+ * clearing another member's.
1396
+ *
1397
+ * Reads the row's live StampId first (absent → quiet no-op, mirroring
1398
+ * {@link registerMemberPeer}'s insert-if-absent, so a repeated or restarted cleanup
1399
+ * is not a silent zero-row "success" the caller cannot distinguish from a real one),
1400
+ * then runs ONE transaction pairing the delete with its `Strand.Revocation`
1401
+ * tombstone — `MemberPeer.RevocationRecorded` requires it, and the retirement is
1402
+ * what keeps the captured registration approval from re-binding the cleared row.
1403
+ *
1404
+ * `MemberPeer` rows do NOT cascade when the member is revoked (`MemberExists` runs on
1405
+ * insert only, and nothing deletes them), so a removed member's peer bindings survive
1406
+ * as orphans — enumerate them with {@link listMemberPeers}, which is how a manager
1407
+ * discovers what to clear. Only that member can sign the self branch, and a member being
1408
+ * removed against its will has no reason to cooperate — hence the manager branch, which
1409
+ * is the cleanup path after a revocation.
1410
+ *
1411
+ * The manager branch is deliberately NOT gated on the member already being gone: a
1412
+ * manager may clear a still-present member's binding too, matching "any manager can
1413
+ * remove any member". It verifies the manager-remove-tagged
1414
+ * `digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId)`
1415
+ * against a `committed.Manager` row; the self branch verifies the remove-tagged
1416
+ * sibling against the row's own member key.
1417
+ *
1418
+ * Cleanup is an explicit follow-up call by the revoking manager, never a cascade inside
1419
+ * {@link revokeMember}: a revocation that also had to clear an unbounded number of peer
1420
+ * rows in the same transaction would couple two concerns and make its failure modes
1421
+ * harder to reason about.
1422
+ *
1423
+ * @param db - The closed strand's database.
1424
+ * @param params - Either the owning member's keypair, or a manager's keypair plus the
1425
+ * target member key.
1426
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1427
+ * @throws If `MemberPeer.Authorized` rejects (neither branch satisfied), or if the row
1428
+ * is still present after the delete (see the point-lookup note below).
1429
+ * @throws {StrandTransactionBusyError} Under `joinOpenTransaction: false`, when another
1430
+ * transaction is open; nothing was written.
1431
+ */
1432
+ export async function removeMemberPeer(
1433
+ db: Database,
1434
+ params: RemoveMemberPeerParams,
1435
+ options?: StrandWriteOptions,
1436
+ ): Promise<void> {
1437
+ const memberKey = 'memberKeyPair' in params ? params.memberKeyPair.publicKeyB64 : params.memberKey;
1438
+ const { peerId } = params;
1439
+
1440
+ const stampId = await memberPeerStampId(db, memberKey, peerId);
1441
+ if (stampId == null) {
1442
+ log('MemberPeer (%s, %s) already absent; skipping', memberKey, peerId);
1443
+ return;
1444
+ }
1445
+
1446
+ const removal = 'memberKeyPair' in params
1447
+ ? ownMemberPeerRemovalStatements(params, stampId)
1448
+ : memberPeerRemovalByManagerStatements(params, stampId);
1449
+ await execStrandTransaction(db, removal, options);
1450
+
1451
+ // NOTE: the delete's `where` puts an equality on BOTH composite-PK columns, which the
1452
+ // optimystic vtab module serves via a single-key seek that can MISS on a networked
1453
+ // strand (see scanMemberPeers' doc). A missed seek deletes zero rows and still
1454
+ // reports success. `Revocation.RowIsGone` now catches that first — the tombstone filed
1455
+ // in the same transaction refuses to retire a stamp whose row is still visible, so the
1456
+ // commit above fails loudly and this re-check is belt-and-braces behind it. Tracked as
1457
+ // `debt-composite-pk-point-lookup-unreliable-untracked`; this is an availability
1458
+ // failure mode, never a security one (a miss removes nothing, it never over-removes).
1459
+ if (await memberPeerStampId(db, memberKey, peerId) != null) {
1460
+ throw new Error(`MemberPeer (${memberKey}, ${peerId}) still present after delete`);
1461
+ }
1462
+ log('Removed MemberPeer (%s, %s)', memberKey, peerId);
1463
+ }
1464
+
1465
+ /** The self branch: the owning member signs the remove-tagged digest + tombstone. */
1466
+ function ownMemberPeerRemovalStatements(params: RemoveOwnPeerParams, stampId: string): StrandStatements {
1467
+ const { memberKeyPair, peerId } = params;
1468
+ const memberKey = memberKeyPair.publicKeyB64;
1469
+ const removal: StrandStatements = {
1470
+ sql: `delete from Strand.MemberPeer
1471
+ with context Signature = :peerSignature, ManagerKey = null, ManagerSignature = null
1472
+ where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1473
+ params: {
1474
+ peerSignature: signStrandApproval(['Strand.MemberPeer', 'remove', memberKey, peerId, stampId], memberKeyPair.privateKeyB64),
1475
+ peerMemberKey: memberKey,
1476
+ peerId,
1477
+ },
1478
+ };
1479
+ return combineStatements(removal, revocationStatement('MemberPeer', stampId, memberKeyPair));
1480
+ }
1481
+
1482
+ /**
1483
+ * The manager branch: a manager signs the manager-remove-tagged digest + tombstone.
1484
+ *
1485
+ * NOTE: clearing an ORPHANED binding (one whose member was already removed) also
1486
+ * forgets the NETWORK DENIAL of that peer id. The orphaned `MemberPeer` row IS
1487
+ * the record the strand revoked-peer gate derives its deny set from
1488
+ * (`strand-revocation-enforcer.ts`) — the `Strand.Revocation` tombstone keeps
1489
+ * only the stamp, not the peer id — so once the orphan is cleared, remaining
1490
+ * members' nodes resume answering and dialing that peer. Only clear a binding
1491
+ * that should never have existed, or a peer that is truly gone.
1492
+ */
1493
+ function memberPeerRemovalByManagerStatements(
1494
+ params: RemoveMemberPeerByManagerParams,
1495
+ stampId: string,
1496
+ ): StrandStatements {
1497
+ const { managerKeyPair, memberKey, peerId } = params;
1498
+ const removal: StrandStatements = {
1499
+ sql: `delete from Strand.MemberPeer
1500
+ with context Signature = null, ManagerKey = :peerManagerKey, ManagerSignature = :peerManagerSignature
1501
+ where MemberKey = :peerMemberKey and PeerId = :peerId;`,
1502
+ params: {
1503
+ peerManagerKey: managerKeyPair.publicKeyB64,
1504
+ peerManagerSignature: signStrandApproval(
1505
+ ['Strand.MemberPeer', 'manager-remove', memberKey, peerId, stampId],
1506
+ managerKeyPair.privateKeyB64,
1507
+ ),
1508
+ peerMemberKey: memberKey,
1509
+ peerId,
1510
+ },
1511
+ };
1512
+ return combineStatements(removal, revocationStatement('MemberPeer', stampId, managerKeyPair));
1513
+ }
1514
+
1515
+ // ── Manager rotation (add / remove RBAC admins) ───────────────────────────────
1516
+
1517
+ /**
1518
+ * The live `(Generation, StampId)` of the `Strand.Manager` row keyed by
1519
+ * `memberKey`, or `undefined` if no such row is visible.
1520
+ *
1521
+ * `Manager`'s primary key is the single `MemberKey` column, so any where-equality
1522
+ * on it is a full-PK point lookup — unreliable on a networked strand (see
1523
+ * {@link scanMemberPeers}). An unfiltered scan + JavaScript filter instead, same
1524
+ * as {@link memberStampId}. Serves both {@link addManager} (the authorizer's
1525
+ * generation) and {@link removeManager} (the target's stamp).
1526
+ */
1527
+ async function managerRow(db: Database, memberKey: string): Promise<{ generation: number; stampId: string } | undefined> {
1528
+ for await (const row of db.eval('select MemberKey, Generation, StampId from Strand.Manager')) {
1529
+ if (row.MemberKey === memberKey) {
1530
+ return { generation: Number(row.Generation), stampId: row.StampId as string };
1531
+ }
1532
+ }
1533
+ return undefined;
1534
+ }
1535
+
1536
+ /** Parameters for {@link addManager}. */
1537
+ export interface AddManagerParams {
1538
+ /**
1539
+ * An EXISTING manager's strand keypair. Its `publicKeyB64` must already be a
1540
+ * `Strand.Manager` row (the existing-manager branch of `Manager.Authorized`
1541
+ * rejects a non-manager once the founder manager exists); it signs the
1542
+ * add-tagged digest over the new manager key, generation, and fresh stamp,
1543
+ * and is bound as `context.ManagerKey`.
1544
+ */
1545
+ byManagerKeyPair: Ed25519KeyPair;
1546
+ /** The member key to promote — the new `Manager.MemberKey` row. */
1547
+ newManagerKey: string;
1548
+ }
1549
+
1550
+ /**
1551
+ * Promote a key that is ALREADY a `Strand.Member` to `Manager` on the signature of
1552
+ * an existing manager — use {@link admitManager} to admit and promote atomically.
1553
+ *
1554
+ * The promotion branch of `Manager.Authorized` requires the authorizer to be a
1555
+ * `Manager` row of STRICTLY SMALLER `Generation` than the new row, verifying
1556
+ * `digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId)`
1557
+ * against `context.ManagerKey`. So this writer reads the authorizer's generation,
1558
+ * seats the new manager at that value + 1 (the natural successor; the schema
1559
+ * enforces only the strict ordering), mints a fresh stamp, and signs the
1560
+ * add-tagged digest over all three: the generation inside the signed digest keeps
1561
+ * a captured promotion from replaying at a different generation, and the stamp
1562
+ * keeps it from re-seating a second incarnation once this one is removed.
1563
+ * `new.Generation` is an SQL INTEGER digest arg — the digest framing is
1564
+ * type-tagged, so the TS side signs over the NUMBER itself
1565
+ * (see {@link signStrandApproval}).
1566
+ *
1567
+ * The strict ordering — not this writer — is what makes a same-transaction takeover
1568
+ * impossible; the `Manager.Generation` column comment in `schemas/strand.qsql` carries
1569
+ * that argument. All this writer owes it is a generation strictly above the
1570
+ * authorizer's, inside the signed payload.
1571
+ *
1572
+ * When the authorizer has NO `Manager` row (a non-manager signer, or an open
1573
+ * strand with no managers at all), the scan finds nothing and the writer falls
1574
+ * back to generation 1 and issues the insert anyway — deliberately letting the
1575
+ * SCHEMA be the rejector (`Manager.Authorized` / `OnlyClosed`), not a
1576
+ * writer-thrown error, so enforcement is pinned where it actually lives.
1577
+ *
1578
+ * @param db - The closed strand's database (founder manager already seated).
1579
+ * @param params - The authorizing manager's keypair and the new manager key.
1580
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1581
+ * @throws If `Manager.Authorized` rejects, or if `Manager.MemberExists` rejects because
1582
+ * `newManagerKey` holds no `Member` row (admit it first, or use
1583
+ * {@link admitManager}); the insert rolls back.
1584
+ */
1585
+ export async function addManager(
1586
+ db: Database,
1587
+ params: AddManagerParams,
1588
+ options?: StrandWriteOptions,
1589
+ ): Promise<void> {
1590
+ const { byManagerKeyPair, newManagerKey } = params;
1591
+ const promotion = await managerAddStatement(db, params);
1592
+ await execStrandTransaction(db, promotion, options);
1593
+ log('Added manager %s (generation %d) by %s', newManagerKey, promotion.params.managerGeneration,
1594
+ byManagerKeyPair.publicKeyB64);
1595
+ }
1596
+
1597
+ /**
1598
+ * The `Strand.Manager` insert behind {@link addManager}: reads the authorizer's generation,
1599
+ * seats the new manager one above it under a fresh stamp, and signs all three.
1600
+ */
1601
+ async function managerAddStatement(db: Database, params: AddManagerParams): Promise<StrandStatements> {
1602
+ const { byManagerKeyPair, newManagerKey } = params;
1603
+ const authorizer = await managerRow(db, byManagerKeyPair.publicKeyB64);
1604
+ // NOTE: +1 saturates at Number.MAX_SAFE_INTEGER, where the successor compares equal
1605
+ // to its authorizer and the schema rejects. Unreachable while generations only ever
1606
+ // grow by 1 from 0, but the schema enforces ordering, not adjacency, so a manager may
1607
+ // seat a successor at any larger value; if arbitrary generations ever become writable
1608
+ // from outside this function, clamp or reject here rather than emitting a dead row.
1609
+ const generation = authorizer == null ? 1 : authorizer.generation + 1;
1610
+ const stampId = generateStrandStampId();
1611
+ return {
1612
+ sql: `insert into Strand.Manager (MemberKey, Generation, StampId)
1613
+ with context ManagerKey = :managerAuthorizerKey, Signature = :managerSignature
1614
+ values (:managerMemberKey, :managerGeneration, :managerStampId);`,
1615
+ params: {
1616
+ managerAuthorizerKey: byManagerKeyPair.publicKeyB64,
1617
+ managerSignature: signStrandApproval(
1618
+ ['Strand.Manager', 'add', newManagerKey, generation, stampId],
1619
+ byManagerKeyPair.privateKeyB64,
1620
+ ),
1621
+ managerMemberKey: newManagerKey,
1622
+ managerGeneration: generation,
1623
+ managerStampId: stampId,
1624
+ },
1625
+ };
1626
+ }
1627
+
1628
+ /** Parameters for {@link admitManager} — the same shape as {@link AddManagerParams}. */
1629
+ export type AdmitManagerParams = AddManagerParams;
1630
+
1631
+ /**
1632
+ * Admit a key as a `Member` AND promote it to `Manager`, atomically — the one-step
1633
+ * pair for a key that is not in the strand yet. {@link addManager} alone promotes a
1634
+ * key that is already a member.
1635
+ *
1636
+ * ONE transaction, because `Manager.MemberExists` is a deferred check reading the LIVE
1637
+ * `Member` table: the sibling `Member` insert satisfies it at the shared commit, and a
1638
+ * rejection of either half rolls BOTH rows back — never a `Member` row seated by a
1639
+ * failed promotion, nor a `Manager` row with no member behind it.
1640
+ *
1641
+ * NO new authority: both halves are signed by the SAME manager, over two distinct
1642
+ * action-tagged digests — `digest('Strand.Member', 'add', …)` for the admission and
1643
+ * `digest('Strand.Manager', 'add', …)` for the promotion — each bound to its own row's
1644
+ * fresh stamp. This composes exactly the two approvals that manager could already
1645
+ * issue separately.
1646
+ *
1647
+ * The promoting manager must be a PRE-transaction manager: the direct-admit branch of
1648
+ * `Member.Authorized` reads `committed.Manager`, so `admitManager` cannot be chained —
1649
+ * a manager seated by an `admitManager` earlier in the SAME transaction cannot admit
1650
+ * the next one. (A same-transaction promotion CHAIN rooted at a pre-existing manager
1651
+ * is fine; only the `Member` half needs the committed authorizer.)
1652
+ *
1653
+ * NOT insert-if-absent: a repeat call for a key that is already a member fails on the
1654
+ * `Member` primary key, matching {@link addMemberByManager}'s unguarded shape.
1655
+ *
1656
+ * @param db - The closed strand's database (founder manager already seated).
1657
+ * @param params - The authorizing manager's keypair and the key to admit + promote.
1658
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1659
+ * @throws If either half is rejected (`Member.Authorized`, `Manager.Authorized`, a
1660
+ * duplicate `Member` key, …); the whole transaction rolls back, leaving neither row.
1661
+ */
1662
+ export async function admitManager(
1663
+ db: Database,
1664
+ params: AdmitManagerParams,
1665
+ options?: StrandWriteOptions,
1666
+ ): Promise<void> {
1667
+ const { byManagerKeyPair, newManagerKey } = params;
1668
+ const admission = memberAddByManagerStatement({ managerKeyPair: byManagerKeyPair, memberKey: newManagerKey });
1669
+ const promotion = await managerAddStatement(db, params);
1670
+ await execStrandTransaction(db, combineStatements(admission, promotion), options);
1671
+ log('Admitted + promoted manager %s by %s', newManagerKey, byManagerKeyPair.publicKeyB64);
1672
+ }
1673
+
1674
+ /** Parameters for {@link removeManager}. */
1675
+ export interface RemoveManagerParams {
1676
+ /**
1677
+ * The keypair authorizing the removal, bound as `context.ManagerKey`. For an
1678
+ * ADMIN removal this is a DIFFERENT existing manager, signing the
1679
+ * remove-tagged digest (satisfying the existing-manager branch); for a
1680
+ * SELF-resignation it is the target's OWN keypair (`publicKeyB64 ===
1681
+ * targetManagerKey`), signing the resign-tagged digest (satisfying the
1682
+ * former-manager self branch). The writer picks the tag by comparing the keys —
1683
+ * see {@link removeManager}. Either way this keypair also files the
1684
+ * accompanying tombstone, which requires it to be a PRE-transaction MEMBER
1685
+ * (`Revocation.Authorized` reads `committed.Member`) — true for every manager,
1686
+ * since managers are members.
1687
+ */
1688
+ byManagerKeyPair: Ed25519KeyPair;
1689
+ /** The `Manager.MemberKey` row to delete. */
1690
+ targetManagerKey: string;
1691
+ }
1692
+
1693
+ /**
1694
+ * Remove a `Manager` row — either an admin removing a different manager or a
1695
+ * manager resigning itself.
1696
+ *
1697
+ * Reads the target row's live `(Generation, StampId)` first (absent → quiet
1698
+ * no-op, same restart-safe shape as {@link revokeMember}), then runs ONE
1699
+ * transaction pairing the delete with its `Strand.Revocation` tombstone
1700
+ * (`Manager.RevocationRecorded`). The two delete-side branches of
1701
+ * `Manager.Authorized` verify DIFFERENT action-tagged digests, each bound to
1702
+ * `(old.MemberKey, old.StampId)`:
1703
+ * - **Admin removal** (`byManagerKeyPair` is a different existing manager): the
1704
+ * removal branch verifies `digest('Strand.Manager', 'remove', old.MemberKey,
1705
+ * old.StampId)` against `A.MemberKey = context.ManagerKey`. Deliberately NO
1706
+ * generation condition: a later-generation manager may remove an
1707
+ * earlier-generation one (generation is lineage, not privilege), and deletes
1708
+ * are safe once inserts are — every accepting branch requires a `Manager` row
1709
+ * in the post-image, and the promotion branch's generation ordering keeps
1710
+ * attacker rows out of it. The stamp binding is what makes a captured removal
1711
+ * single-use: a re-promoted manager carries a fresh stamp the old approval
1712
+ * does not match — closing the removal half of
1713
+ * `bug-strand-manager-authority-antireplay` (the insert half was already
1714
+ * closed by the generation-bearing promotion payload).
1715
+ * - **Self-resignation** (`byManagerKeyPair` IS the target): the former-manager
1716
+ * self branch verifies `digest('Strand.Manager', 'resign', old.MemberKey,
1717
+ * old.StampId)` against `old.MemberKey` itself. The distinct `'resign'` tag is
1718
+ * belt-and-braces (different verifying keys); the anti-replay fix is the stamp.
1719
+ *
1720
+ * The caller selects the case purely by which keypair it passes; the only
1721
+ * branching here is the action tag, chosen by comparing the signer to the target.
1722
+ *
1723
+ * Deferred (subquery-bearing) CHECK constraints are evaluated on DELETE as well
1724
+ * as INSERT by Quereus plus the Optimystic vtab session — enforcement lives above
1725
+ * the transactor (proven on the network transactor by
1726
+ * `strand-membership-network-transactor-parity.spec.ts`), so `Manager.Authorized`
1727
+ * — deferred — IS enforced here: a signer that is neither an existing manager nor
1728
+ * the target itself is rejected at commit.
1729
+ *
1730
+ * THE SOLE MANAGER CANNOT ORDINARILY RESIGN — it SEALS instead. The `'resign'`
1731
+ * branch of `Manager.Authorized` requires at least one manager in the POST-delete
1732
+ * image, so a resignation that would empty the `Manager` table is schema-rejected;
1733
+ * emptying it deliberately is a distinct, self-signed act — {@link sealStrand} —
1734
+ * that permanently freezes admission. This writer refuses the sole-manager
1735
+ * self-resignation up front with an error naming `sealStrand`; that TS guard is UX,
1736
+ * not the trust boundary — the schema rejects a mis-counted `'resign'` approval
1737
+ * regardless. Reading the count inside a caller-joined transaction is correct: an
1738
+ * add-then-resign hand-off composed in one transaction sees 2 and passes, while
1739
+ * the already-rejected delete-then-add swap sees 1 and is refused here as well as
1740
+ * by the schema. The bootstrap branch of `Manager.Authorized` is gated to INSERTs
1741
+ * (`old.MemberKey is null`), so it does not waive the signature check on a delete
1742
+ * that drops the count toward zero: a second-to-last removal is authorized
1743
+ * exactly like any other.
1744
+ *
1745
+ * HAND-OFF ORDER: a sole manager transferring control must ADD the successor FIRST
1746
+ * and resign SECOND. A same-transaction delete-then-insert swap is rejected — the
1747
+ * bootstrap branch is gated to the founding state (at most one `Member`), and the
1748
+ * successor's insert has no other manager to authorize it once the sole manager's
1749
+ * row is gone.
1750
+ *
1751
+ * Cross-node caveat: the `'resign'`/`'seal'` branches count rows visible to THIS
1752
+ * transaction, so two nodes concurrently removing different managers can each see
1753
+ * a survivor and still converge to zero — an unintended seal. Noted in the schema
1754
+ * (the `Manager` table's seal-propagation NOTE); a cross-node floor is not
1755
+ * attempted here.
1756
+ *
1757
+ * @param db - The closed strand's database.
1758
+ * @param params - The authorizing keypair and the target manager key.
1759
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1760
+ * @throws If the signer IS the target and it is the sole manager (before writing —
1761
+ * the operation the caller wants is {@link sealStrand}), or if `Manager.Authorized`
1762
+ * rejects (a signer that is neither another existing manager nor the target
1763
+ * itself, or a raw sole-manager `'resign'` whose post-image count is zero); the
1764
+ * whole delete+tombstone transaction rolls back.
1765
+ */
1766
+ export async function removeManager(
1767
+ db: Database,
1768
+ params: RemoveManagerParams,
1769
+ options?: StrandWriteOptions,
1770
+ ): Promise<void> {
1771
+ const { byManagerKeyPair, targetManagerKey } = params;
1772
+ const target = await managerRow(db, targetManagerKey);
1773
+ if (target == null) {
1774
+ log('Manager %s already absent; skipping removal', targetManagerKey);
1775
+ return;
1776
+ }
1777
+ const tag = byManagerKeyPair.publicKeyB64 === targetManagerKey ? 'resign' : 'remove';
1778
+ if (tag === 'resign' && await strandTableCount(db, 'Manager') === 1) {
1779
+ throw new Error(
1780
+ 'Cannot resign as the SOLE manager: an ordinary resignation never empties the '
1781
+ + 'Manager table. To permanently freeze admission instead, call sealStrand.',
1782
+ );
1783
+ }
1784
+ const signature = signStrandApproval(
1785
+ ['Strand.Manager', tag, targetManagerKey, target.stampId],
1786
+ byManagerKeyPair.privateKeyB64,
1787
+ );
1788
+ await execStrandTransaction(
1789
+ db,
1790
+ managerRemovalStatements(byManagerKeyPair, signature, targetManagerKey, target.stampId),
1791
+ options,
1792
+ );
1793
+ log('Removed manager %s by %s', targetManagerKey, byManagerKeyPair.publicKeyB64);
1794
+ }
1795
+
1796
+ /**
1797
+ * The `Strand.Manager` delete plus its tombstone, shared by {@link removeManager} and
1798
+ * {@link sealStrand}, which differ only in the action tag `signature` was minted under.
1799
+ */
1800
+ function managerRemovalStatements(
1801
+ signer: Ed25519KeyPair,
1802
+ signature: string,
1803
+ targetManagerKey: string,
1804
+ targetStampId: string,
1805
+ ): StrandStatements {
1806
+ const removal: StrandStatements = {
1807
+ sql: `delete from Strand.Manager
1808
+ with context ManagerKey = :managerRemoverKey, Signature = :managerRemoverSignature
1809
+ where MemberKey = :managerMemberKey;`,
1810
+ params: {
1811
+ managerRemoverKey: signer.publicKeyB64,
1812
+ managerRemoverSignature: signature,
1813
+ managerMemberKey: targetManagerKey,
1814
+ },
1815
+ };
1816
+ return combineStatements(removal, revocationStatement('Manager', targetStampId, signer));
1817
+ }
1818
+
1819
+ // ── Sealing (the sole manager permanently freezes admission) ──────────────────
1820
+
1821
+ /** Parameters for {@link sealStrand}. */
1822
+ export interface SealStrandParams {
1823
+ /**
1824
+ * The SOLE manager's strand keypair. Its `publicKeyB64` must be the one live
1825
+ * `Strand.Manager` row; it signs the seal-tagged digest over its own key and
1826
+ * live stamp, and files the accompanying tombstone (it is still a committed
1827
+ * member, which is what `Revocation.Authorized` checks).
1828
+ */
1829
+ managerKeyPair: Ed25519KeyPair;
1830
+ }
1831
+
1832
+ /**
1833
+ * Seal the strand: the SOLE manager deliberately steps down, permanently freezing
1834
+ * membership.
1835
+ *
1836
+ * A sealed strand has zero `Manager` rows, and every admission path — issuing or
1837
+ * cancelling an invitation, consuming one issued BEFORE the seal
1838
+ * (`ConsumedInvite.NotSealed`), admitting a member, promoting a manager — requires
1839
+ * one, so nobody can ever be let in again. That freeze is a privacy guarantee to
1840
+ * everyone who contributed: no key holds the power to admit a party who would then
1841
+ * read the strand's whole history. Sealing is irreversible — the founding branch
1842
+ * of `Manager.Authorized` closes for good once any manager stamp is retired into
1843
+ * `Strand.Revocation`. What remains possible: members may {@link leaveStrand}
1844
+ * (floored by `MinOneMember`), manage their own `MemberPeer` rows, and file
1845
+ * `Revocation` tombstones.
1846
+ *
1847
+ * The `'seal'` branch of `Manager.Authorized` accepts a self-signed delete only
1848
+ * when the POST-image manager count is ZERO — a distinct action tag from
1849
+ * `'resign'`, so a captured resignation approval can never seal and a raw writer
1850
+ * cannot seal by accident. Like {@link removeManager}, the delete pairs with its
1851
+ * `Strand.Revocation` tombstone in one transaction.
1852
+ *
1853
+ * The TS count/identity checks below are UX guards, not the trust boundary — the
1854
+ * schema rejects a mis-tagged or mis-counted approval regardless of what this
1855
+ * writer (or any raw writer) presents.
1856
+ *
1857
+ * NOTE: a seal approval is a bearer token for its row incarnation, like every other
1858
+ * self-signed approval here — but the `'seal'` branch's post-image count of 0 is
1859
+ * satisfied for EVERY row deleted in the same transaction, so N managers each
1860
+ * presenting a valid `'seal'` approval seal jointly (pinned as an accepted case in
1861
+ * `strand-seal.spec.ts`). Unreachable today: this writer mints a seal signature only
1862
+ * while its caller is the SOLE manager, and spends it immediately, so a second
1863
+ * manager never has one to combine. If seal-approval MINTING is ever split from
1864
+ * spending — offline signing, a two-phase seal, a queued approval — one manager
1865
+ * holding another's unspent seal approval could freeze the strand irreversibly
1866
+ * without its current consent; re-gate here (or narrow the schema branch) then.
1867
+ *
1868
+ * @param db - The closed strand's database.
1869
+ * @param params - The sole manager's own keypair.
1870
+ * @param options - Whether to join an already-open transaction (see {@link StrandWriteOptions}).
1871
+ * @throws If more than one manager exists (the caller wants {@link removeManager}),
1872
+ * or the supplied keypair holds no `Manager` row while one exists, or the strand
1873
+ * holds no `Manager` row and has never retired one (not founded yet — see
1874
+ * {@link isStrandSealed}), or any schema constraint rejects; the whole
1875
+ * delete+tombstone transaction rolls back. A strand that is ALREADY sealed logs
1876
+ * and returns quietly (restart-safe, matching {@link bootstrapFounderMembership}
1877
+ * and {@link removeManager}'s absent-row no-op).
1878
+ */
1879
+ export async function sealStrand(
1880
+ db: Database,
1881
+ params: SealStrandParams,
1882
+ options?: StrandWriteOptions,
1883
+ ): Promise<void> {
1884
+ const { managerKeyPair } = params;
1885
+ const managerCount = await strandTableCount(db, 'Manager');
1886
+ if (managerCount === 0) {
1887
+ // Zero managers is NOT by itself a seal — a closed strand between its Header
1888
+ // and Manager founding inserts looks identical. The retired `Manager` stamp is
1889
+ // what separates "frozen forever" from "still foundable"; returning quietly on
1890
+ // the latter would report a seal that never happened.
1891
+ if (await strandHasManagerRevocation(db)) {
1892
+ log('Strand already sealed (no Manager rows, manager stamp retired); skipping seal');
1893
+ return;
1894
+ }
1895
+ throw new Error(
1896
+ 'Cannot seal: the strand holds no Manager row and has never retired one — it is '
1897
+ + 'not founded yet, not sealed. Bootstrap the founding manager first.',
1898
+ );
1899
+ }
1900
+ if (managerCount > 1) {
1901
+ throw new Error(
1902
+ `Cannot seal: ${managerCount} managers exist and sealing is the SOLE manager's act. `
1903
+ + 'The other managers must step down first (removeManager).',
1904
+ );
1905
+ }
1906
+ const self = await managerRow(db, managerKeyPair.publicKeyB64);
1907
+ if (self == null) {
1908
+ throw new Error('Cannot seal: the supplied keypair does not hold the sole Manager row.');
1909
+ }
1910
+ const signature = signStrandApproval(
1911
+ ['Strand.Manager', 'seal', managerKeyPair.publicKeyB64, self.stampId],
1912
+ managerKeyPair.privateKeyB64,
1913
+ );
1914
+ await execStrandTransaction(
1915
+ db,
1916
+ managerRemovalStatements(managerKeyPair, signature, managerKeyPair.publicKeyB64, self.stampId),
1917
+ options,
1918
+ );
1919
+ log('Sealed strand: sole manager %s stepped down', managerKeyPair.publicKeyB64);
1920
+ }
1921
+
1922
+ /**
1923
+ * Whether the strand is `Type = 'c'` (closed). Reads the singleton `Header` via
1924
+ * the same `db.eval` scan idiom as {@link strandTableCount}; a strand with no
1925
+ * `Header` row yet reports `false`.
1926
+ */
1927
+ async function strandIsClosed(db: Database): Promise<boolean> {
1928
+ for await (const row of db.eval('select Type from Strand.Header')) {
1929
+ return row.Type === 'c';
1930
+ }
1931
+ return false;
1932
+ }
1933
+
1934
+ /**
1935
+ * Whether the strand is sealed: a CLOSED strand that holds no `Manager` rows AND
1936
+ * has retired at least one `Manager` stamp into `Strand.Revocation`.
1937
+ *
1938
+ * All three conjuncts are load-bearing:
1939
+ * - `Header.Type` — an OPEN strand never holds `Manager` rows at all
1940
+ * (`Manager.OnlyClosed`), so a bare manager-count test would report every open
1941
+ * strand as sealed.
1942
+ * - the manager count — sealing IS the empty `Manager` table.
1943
+ * - the retired `Manager` stamp — "sealed" means admission is frozen FOREVER, and
1944
+ * what makes it permanent is the founding branch of `Manager.Authorized`
1945
+ * refusing to re-seat a generation-0 manager once any `Manager` stamp has been
1946
+ * retired. Without this conjunct a closed strand that is merely NOT FOUNDED YET
1947
+ * reads as sealed: {@link bootstrapFounderMembership} commits `Header`,
1948
+ * `Member` and `Manager` as three sequential statements, so the window between
1949
+ * the first and the last is exactly "closed, zero managers, still foundable".
1950
+ *
1951
+ * Reads locally visible rows — a node that has not yet converged on the seal
1952
+ * still reports `false` (the schema's seal-propagation NOTE).
1953
+ *
1954
+ * @param db - The strand's database.
1955
+ * @returns `true` iff the strand is closed, holds zero `Manager` rows, and has
1956
+ * permanently closed its founding branch.
1957
+ */
1958
+ export async function isStrandSealed(db: Database): Promise<boolean> {
1959
+ return await strandIsClosed(db)
1960
+ && await strandTableCount(db, 'Manager') === 0
1961
+ && await strandHasManagerRevocation(db);
1962
+ }