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