@serfab/cadre-core 0.9.0 → 0.11.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 (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,948 +1,2406 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { Database, registerPlugin } from '@quereus/quereus';
4
- import type { VTablePluginInfo, FunctionPluginInfo } from '@quereus/quereus';
5
- import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
6
- import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
- import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
- import type { Libp2p } from '@libp2p/interface';
9
- import type { IRepo } from '@optimystic/db-core';
10
- import type { StrandRow, PeerAddressRecord, DeviceTokenRecord, PushPlatform } from './types.js';
11
- import { CONTROL_SCHEMA } from './control-schema.js';
12
- import { canonicalDatetime } from './canonical-datetime.js';
13
-
14
- const log = debug('sereus:cadre:control-db');
15
- const timing = debug('sereus:cadre:timing');
16
-
17
- /**
18
- * Generate a unique stamp ID for transaction authorization.
19
- * Format: 32 bytes base64url encoded
20
- * - First 16 bytes: SHA-256 hash of peer ID (for distributed uniqueness)
21
- * - Last 16 bytes: Random bytes (for collision resistance)
22
- */
23
- export function generateStampId(peerId: string): string {
24
- // Hash the peer ID and get first 16 bytes (128 bits). A purely local ID
25
- // generator: never signed/verified against SQL, so the framed single-field
26
- // digest is fine (the changed framing is not cross-checked anywhere).
27
- const peerIdHash = digest([peerId], 'sha256', 'bytes') as Uint8Array;
28
- const peerIdHashPart = peerIdHash.slice(0, 16);
29
-
30
- // Generate 16 random bytes
31
- const randomPart = randomBytes(128, 'bytes') as Uint8Array;
32
-
33
- // Combine peer ID hash and random bytes
34
- const combined = new Uint8Array(32);
35
- combined.set(peerIdHashPart, 0);
36
- combined.set(randomPart, 16);
37
-
38
- // Convert to base64url
39
- return uint8ArrayToString(combined, 'base64url');
40
- }
41
-
42
- /**
43
- * Parse a stored Quereus `datetime` value into epoch milliseconds.
44
- *
45
- * Quereus canonicalises a `datetime` column to a bare UTC `PlainDateTime` string
46
- * (e.g. `2026-06-04T12:34:56`, no `Z`). JS `Date` reads a timezone-less
47
- * date-time as LOCAL, so we append `Z` when no offset/zone is present to keep the
48
- * value anchored to UTC. Numbers (already epoch ms) pass through.
49
- */
50
- function parseStoredDatetimeMs(value: string | number): number {
51
- if (typeof value === 'number') return value;
52
- const hasZone = /[zZ]$|[+-]\d\d:?\d\d$/.test(value);
53
- return new Date(hasZone ? value : `${value}Z`).getTime();
54
- }
55
-
56
- /**
57
- * Build the canonical authorization message that owner signatures are bound to.
58
- *
59
- * The message is a SINGLE framed SHA-256 digest over the ordered tuple of fields (the
60
- * crypto plugin's injective multi-field encoding), in a fixed field order, with the
61
- * single-use StampId as the final field:
62
- *
63
- * message = sha256(encodeFields([field_1, field_2, ..., StampId])) // raw digest bytes
64
- *
65
- * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
66
- * verify the identical bytes with one variadic call
67
- * (`verify(digest(field_1, ..., StampId), context.Signature, A.Key, 'ed25519')`): SQL
68
- * `digest(...)` returns the base64url string of the same digest, which `verify`'s default
69
- * base64url input encoding decodes back to those raw bytes — so signer and verifier
70
- * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
71
- * `cast(... as text)` / `coalesce(...,'')`; the TS args are strings), so the per-field
72
- * type tags agree. This binds the signature to the row contents (not a bare stamp),
73
- * closing the captured-stamp replay / privilege-escalation hole. Single source of truth:
74
- * every signed writer (and every test/harness signer) MUST build the message through this
75
- * function in the schema's field order, or `verify` will reject the row.
76
- */
77
- export function buildAuthorizationMessage(fields: string[]): Uint8Array {
78
- return digest([...fields], 'sha256', 'bytes') as Uint8Array;
79
- }
80
-
81
- /**
82
- * Minimal interface for the CollectionFactory returned by the optimystic plugin.
83
- * We only need the methods we actually use.
84
- */
85
- interface CollectionFactory {
86
- registerLibp2pNode(networkName: string, node: Libp2p, coordinatedRepo: IRepo): void;
87
- shutdown(): Promise<void>;
88
- }
89
-
90
- /** Result of registering the optimystic plugin */
91
- interface OptimysticPluginResult {
92
- collectionFactory: CollectionFactory;
93
- vtables: VTablePluginInfo[];
94
- functions: FunctionPluginInfo[];
95
- /**
96
- * Hydrate Quereus's in-memory catalog from persisted optimystic vtab schemas.
97
- * Must run BEFORE applying CONTROL_SCHEMA on a warm restart so the declarative
98
- * diff sees the existing tables and skips re-emitting CREATE TABLE / CREATE INDEX
99
- * for each persisted control object. Idempotent; `{ tables: 0, indexes: 0 }` on a
100
- * cold/in-memory start. Mirrors the strand path's hydrate-before-apply (see
101
- * compose-strand.ts).
102
- */
103
- hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
104
- [key: string]: unknown;
105
- }
106
-
107
- /**
108
- * Names of the CadreControl tables that {@link ControlDatabase.countRows} can
109
- * read. Constraining to this union keeps the dynamic `from` clause off the
110
- * SQL-injection surface and hands callers (e.g. the integration harness's
111
- * `waitForControlSync`) a typo-proof table argument.
112
- */
113
- export type ControlTable =
114
- | 'OwnerKey'
115
- | 'ValidationKey'
116
- | 'Strand'
117
- | 'CadrePeer'
118
- | 'DeviceToken'
119
- | 'FormationInvite'
120
- | 'FormationUsage';
121
-
122
- /** Runtime guard mirroring {@link ControlTable} for the dynamic-`from` count. */
123
- const CONTROL_TABLES: ReadonlySet<ControlTable> = new Set<ControlTable>([
124
- 'OwnerKey',
125
- 'ValidationKey',
126
- 'Strand',
127
- 'CadrePeer',
128
- 'DeviceToken',
129
- 'FormationInvite',
130
- 'FormationUsage',
131
- ]);
132
-
133
- export interface ControlDatabaseConfig {
134
- /** Party ID for the control network */
135
- partyId: string;
136
- /**
137
- * Optional path to the control schema file.
138
- * If not provided, uses the embedded schema for cross-platform compatibility.
139
- * Only use this if you need to override the default schema (e.g., for testing).
140
- */
141
- schemaPath?: string;
142
- /** Libp2p node for the control network (injected) */
143
- libp2pNode: Libp2p;
144
- /** Coordinated repo from the libp2p node */
145
- coordinatedRepo: IRepo;
146
- }
147
-
148
- /**
149
- * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
150
- * It provides typed query methods for accessing control network data.
151
- */
152
- export class ControlDatabase {
153
- private db: Database | null = null;
154
- private collectionFactory: CollectionFactory | null = null;
155
- private readonly config: ControlDatabaseConfig;
156
- private initialized = false;
157
-
158
- constructor(config: ControlDatabaseConfig) {
159
- this.config = config;
160
- }
161
-
162
- /**
163
- * Initialize the database - load schema and register plugins
164
- */
165
- async initialize(): Promise<void> {
166
- if (this.initialized) {
167
- log('ControlDatabase already initialized');
168
- return;
169
- }
170
-
171
- log('Initializing ControlDatabase for party: %s', this.config.partyId);
172
-
173
- // Create database instance
174
- this.db = new Database();
175
-
176
- // Register crypto plugin (provides digest, sign, verify functions)
177
- let t0 = performance.now();
178
- await registerPlugin(this.db, cryptoPlugin);
179
- timing('[controlDb] cryptoPlugin: %dms', Math.round(performance.now() - t0));
180
- log('Registered crypto plugin');
181
-
182
- // Register optimystic plugin with network transactor as default
183
- t0 = performance.now();
184
- const networkName = `control-${this.config.partyId}`;
185
- const pluginResult = optimysticPlugin(this.db, {
186
- default_transactor: 'network',
187
- default_key_network: 'libp2p',
188
- default_network_name: networkName,
189
- enable_cache: true,
190
- }) as unknown as OptimysticPluginResult;
191
-
192
- // Register vtables and functions manually since we need access to collectionFactory
193
- for (const vtable of pluginResult.vtables) {
194
- this.db.registerModule(vtable.name, vtable.module, vtable.auxData);
195
- }
196
- for (const func of pluginResult.functions) {
197
- this.db.registerFunction(func.schema);
198
- }
199
- timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
200
-
201
- this.collectionFactory = pluginResult.collectionFactory;
202
-
203
- // Inject the libp2p node into the collection factory
204
- t0 = performance.now();
205
- this.collectionFactory.registerLibp2pNode(
206
- networkName,
207
- this.config.libp2pNode,
208
- this.config.coordinatedRepo
209
- );
210
- timing('[controlDb] registerLibp2pNode: %dms', Math.round(performance.now() - t0));
211
- log('Registered libp2p node with collection factory');
212
-
213
- // Network-back the control tables. CONTROL_SCHEMA's `declare schema CadreControl
214
- // { table ... }` tables carry NO per-table `using optimystic(...)`, so storage is
215
- // chosen by the database's DEFAULT vtab. Routing the default to optimystic (with
216
- // the network transactor + this party's control network) is what makes a control
217
- // write replicate peer-to-peer — exactly what connectToStrand does for strand
218
- // tables (compose-strand.ts). Without these two calls the tables fall back to
219
- // Quereus's in-memory vtab and never converge across the cadre.
220
- this.db.setDefaultVtabName('optimystic');
221
- this.db.setDefaultVtabArgs({
222
- networkName,
223
- transactor: 'network',
224
- keyNetwork: 'libp2p',
225
- });
226
- log('Set default vtab to optimystic (networkName=%s, transactor=network)', networkName);
227
-
228
- // Hydrate Quereus's catalog from any persisted optimystic vtab schemas BEFORE
229
- // applying CONTROL_SCHEMA, so a warm restart with persistent storage diffs the
230
- // control DDL against the already-present tables and re-emits nothing — the same
231
- // warm-start regression connectToStrand's hydrate-before-apply guards against.
232
- // No-op on a cold / in-memory start (`{ tables: 0, indexes: 0 }`).
233
- t0 = performance.now();
234
- const hydrated = await pluginResult.hydrate(this.db);
235
- timing('[controlDb] hydrate: %dms (tables=%d, indexes=%d)',
236
- Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
237
- log('Hydrated control catalog (tables=%d, indexes=%d)', hydrated.tables, hydrated.indexes);
238
-
239
- // Load and execute the schema
240
- t0 = performance.now();
241
- await this.loadSchema();
242
- timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
243
-
244
- this.initialized = true;
245
- log('ControlDatabase initialized successfully');
246
- }
247
-
248
- private async loadSchema(): Promise<void> {
249
- let schemaContent: string;
250
-
251
- if (this.config.schemaPath) {
252
- // Load from file if explicitly provided (for testing or custom schemas)
253
- // This only works in Node.js environments
254
- log('Loading schema from file: %s', this.config.schemaPath);
255
-
256
- // Check if we're in a Node.js environment
257
- if (typeof process !== 'undefined' && process.versions?.node) {
258
- try {
259
- // Use require to conditionally load fs only in Node.js.
260
- // This won't be bundled by React Native's Metro bundler — a dynamic
261
- // import() would be statically picked up by Metro, so require is
262
- // intentional here (cross-platform constraint, not lazy CommonJS).
263
- // eslint-disable-next-line @typescript-eslint/no-require-imports
264
- const fs = require('fs/promises');
265
- schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
266
- } catch (error) {
267
- throw new Error(
268
- `Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
269
- { cause: error }
270
- );
271
- }
272
- } else {
273
- throw new Error(
274
- 'Loading schema from file is not supported in React Native. ' +
275
- 'Remove the schemaPath option to use the embedded schema instead.'
276
- );
277
- }
278
- } else {
279
- // Use embedded schema for cross-platform compatibility
280
- log('Using embedded control schema');
281
- schemaContent = CONTROL_SCHEMA;
282
- }
283
-
284
- await this.db!.exec(schemaContent);
285
- log('Schema loaded and executed');
286
- }
287
-
288
- /**
289
- * Query all strands from the control database
290
- */
291
- async queryStrands(): Promise<StrandRow[]> {
292
- this.ensureInitialized();
293
- const results: StrandRow[] = [];
294
- for await (const row of this.db!.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand')) {
295
- results.push({
296
- Id: row.Id as string,
297
- MemberPrivateKey: row.MemberPrivateKey as string | null,
298
- Type: row.Type as 'o' | 'c',
299
- });
300
- }
301
- return results;
302
- }
303
-
304
- /**
305
- * Read a single strand row by id, or null when absent. Single-row sibling of
306
- * {@link queryStrands}; the responder uses it to read a host strand's
307
- * `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
308
- * validated invitee during provision-then-record formation.
309
- */
310
- async queryStrand(strandId: string): Promise<StrandRow | null> {
311
- this.ensureInitialized();
312
- for await (const row of this.db!.eval(
313
- 'select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?',
314
- [strandId]
315
- )) {
316
- return {
317
- Id: row.Id as string,
318
- MemberPrivateKey: row.MemberPrivateKey as string | null,
319
- Type: row.Type as 'o' | 'c',
320
- };
321
- }
322
- return null;
323
- }
324
-
325
- /**
326
- * Count rows in a CadreControl table as seen by THIS database instance.
327
- *
328
- * `table` is validated against {@link CONTROL_TABLES} before it is interpolated
329
- * into the `from` clause: the names are not user input, but the check keeps the
330
- * dynamic query off the injection surface and fails loudly on a typo instead of
331
- * emitting a malformed statement. The count reflects only the rows this node's
332
- * control DB has converged on — in the integration harness that is the owner
333
- * node (one ControlDatabase per party), i.e. the authoritative control-network
334
- * view, not a per-drone convergence guarantee.
335
- */
336
- async countRows(table: ControlTable): Promise<number> {
337
- this.ensureInitialized();
338
- if (!CONTROL_TABLES.has(table)) {
339
- throw new Error(`Unknown CadreControl table: ${table}`);
340
- }
341
- for await (const row of this.db!.eval(`select count(1) as Count from CadreControl.${table}`)) {
342
- return (row.Count as number) ?? 0;
343
- }
344
- return 0;
345
- }
346
-
347
- /**
348
- * Get the underlying database for advanced queries
349
- */
350
- getDatabase(): Database {
351
- this.ensureInitialized();
352
- return this.db!;
353
- }
354
-
355
- /**
356
- * Check whether any owner key exists in the control database.
357
- * Used to decide whether a fresh-party genesis insert is required.
358
- */
359
- async hasOwnerKey(): Promise<boolean> {
360
- this.ensureInitialized();
361
- for await (const row of this.db!.eval('select count(1) as Count from CadreControl.OwnerKey')) {
362
- return (row.Count as number) > 0;
363
- }
364
- return false;
365
- }
366
-
367
- /**
368
- * Idempotent genesis: insert `key` as the founding owner key only when
369
- * the party has none yet. Returns true if it inserted, false if an owner
370
- * key already existed (so a repeat `--owner` start is a no-op).
371
- */
372
- async ensureOwnerKey(key: string): Promise<boolean> {
373
- this.ensureInitialized();
374
- if (await this.hasOwnerKey()) {
375
- log('Owner key already present; skipping genesis insert');
376
- return false;
377
- }
378
- await this.insertOwnerKey(key);
379
- return true;
380
- }
381
-
382
- /**
383
- * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
384
- *
385
- * This is the steady-state trust anchor for seeds: a seed's signer key is
386
- * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
387
- * also the owner-identity source for `queryPeers`, decoupling owner
388
- * status from the libp2p transport peer ID.
389
- */
390
- async getOwnerKeys(): Promise<Set<string>> {
391
- this.ensureInitialized();
392
- const keys = new Set<string>();
393
- for await (const row of this.db!.eval('select Key from CadreControl.OwnerKey')) {
394
- keys.add(row.Key as string);
395
- }
396
- return keys;
397
- }
398
-
399
- /**
400
- * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
401
- */
402
- async queryCadrePeers(): Promise<Array<{ peerId: string; multiaddr: string | null; stampId: string | null; vouchOwner: string | null; vouchSig: string | null }>> {
403
- this.ensureInitialized();
404
- const rows: Array<{ peerId: string; multiaddr: string | null; stampId: string | null; vouchOwner: string | null; vouchSig: string | null }> = [];
405
- for await (const row of this.db!.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
406
- rows.push({
407
- peerId: row.PeerId as string,
408
- multiaddr: (row.Multiaddr as string | null) ?? null,
409
- stampId: (row.StampId as string | null) ?? null,
410
- vouchOwner: (row.VouchOwner as string | null) ?? null,
411
- vouchSig: (row.VouchSig as string | null) ?? null,
412
- });
413
- }
414
- return rows;
415
- }
416
-
417
- /**
418
- * Read a single `CadrePeer` row's single-use `StampId` nonce (null when the row
419
- * does not exist). Used by the owner delete / re-touch paths, which must bind
420
- * their signature to the row's CURRENT nonce ({@link cadrePeerRemoveDigest} /
421
- * {@link cadrePeerVoucherDigest}).
422
- */
423
- async queryCadrePeerStampId(peerId: string): Promise<string | null> {
424
- this.ensureInitialized();
425
- for await (const row of this.db!.eval('select StampId from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
426
- return (row.StampId as string | null) ?? null;
427
- }
428
- return null;
429
- }
430
-
431
- /**
432
- * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
433
- *
434
- * Returns null when no row exists. Missing/legacy column values are coalesced
435
- * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
436
- * verify/freshness gates uniformly reject an unpublished or malformed row.
437
- * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
438
- * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
439
- * the publisher signed.
440
- */
441
- async queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null> {
442
- this.ensureInitialized();
443
- for await (const row of this.db!.eval(
444
- 'select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig from CadreControl.CadrePeer where PeerId = ?',
445
- [peerId]
446
- )) {
447
- const multiaddr = (row.Multiaddr as string | null) ?? '';
448
- return {
449
- peerId: row.PeerId as string,
450
- publicKey: (row.PublicKey as string | null) ?? '',
451
- addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
452
- updatedAt: (row.UpdatedAt as number | null) ?? 0,
453
- sig: (row.Sig as string | null) ?? '',
454
- };
455
- }
456
- return null;
457
- }
458
-
459
- /**
460
- * Apply a peer's own self-signed address-record update to an existing row.
461
- *
462
- * Authorization is carried entirely by the record: the `Sig` column (verified
463
- * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
464
- * the strictly-increasing `UpdatedAt`. No owner key is involved, so this
465
- * is the refresh path for any member — owner or drone — once its row
466
- * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
467
- * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
468
- */
469
- async updateSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
470
- this.ensureInitialized();
471
- const multiaddr = record.addrs.join(',');
472
- await this.db!.exec(`
473
- update CadreControl.CadrePeer
474
- with context OwnerKey = null, Signature = ?
475
- set Multiaddr = ?, UpdatedAt = ?, Sig = ?
476
- where PeerId = ?
477
- `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId]);
478
- log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
479
- }
480
-
481
- /**
482
- * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
483
- *
484
- * Returns null when no row exists. Missing/legacy column values are coalesced to
485
- * their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
486
- * gates uniformly reject an unpublished or malformed row. `platform` is returned
487
- * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
488
- * the self-signature, which covers the platform field).
489
- */
490
- async queryDeviceToken(peerId: string): Promise<DeviceTokenRecord | null> {
491
- this.ensureInitialized();
492
- for await (const row of this.db!.eval(
493
- 'select PeerId, Platform, Token, UpdatedAt, Sig from CadreControl.DeviceToken where PeerId = ?',
494
- [peerId]
495
- )) {
496
- return {
497
- peerId: row.PeerId as string,
498
- platform: (row.Platform as string ?? '') as PushPlatform,
499
- token: (row.Token as string | null) ?? '',
500
- updatedAt: (row.UpdatedAt as number | null) ?? 0,
501
- sig: (row.Sig as string | null) ?? '',
502
- };
503
- }
504
- return null;
505
- }
506
-
507
- /**
508
- * Apply a peer's own self-signed device-token update to an existing row.
509
- *
510
- * Authorization is carried entirely by the record: the `Sig` column (verified by
511
- * the `DeviceToken.AuthorizedUpdate` self-branch against the stored
512
- * `CadrePeer.PublicKey`) plus the strictly-increasing `UpdatedAt`. No owner key
513
- * is involved, so this is the refresh / rotation path for any member once both its
514
- * `CadrePeer` row (for the PublicKey) and its `DeviceToken` row exist. `PeerId` is
515
- * intentionally not in the SET list (immutable; the constraint enforces
516
- * `new.PeerId = old.PeerId`). Mirrors {@link updateSelfPeerRecord}.
517
- */
518
- async updateSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
519
- this.ensureInitialized();
520
- await this.db!.exec(`
521
- update CadreControl.DeviceToken
522
- with context OwnerKey = null, Signature = ?
523
- set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
524
- where PeerId = ?
525
- `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId]);
526
- log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
527
- }
528
-
529
- /**
530
- * Insert the initial owner key (bootstrap - no existing owners required)
531
- */
532
- async insertOwnerKey(key: string): Promise<void> {
533
- this.ensureInitialized();
534
- log('Inserting owner key: %s', key);
535
-
536
- // Bootstrap is authorized by the schema's `(select count(1) from OwnerKey) <= 1`
537
- // branch, so no signature is needed. We still persist a fresh, unique StampId in the
538
- // row's own column to satisfy the not-null/unique anti-replay constraint — the StampId
539
- // is now a real column value, not the optimystic `StampId()` SQL function.
540
- const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
541
- await this.db!.exec(`
542
- insert into CadreControl.OwnerKey (Key, StampId)
543
- with context OwnerKey = null, Signature = null
544
- values (?, ?)
545
- `, [key, stampId]);
546
- log('Owner key inserted');
547
- }
548
-
549
- /**
550
- * Insert a strand into the control database using an owner signature.
551
- *
552
- * The owner signs the canonical row-bound authorization message (see
553
- * {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
554
- * this strand's contents and cannot be transplanted onto an attacker-chosen row. The
555
- * StampId is persisted as a unique column for single-use anti-replay.
556
- *
557
- * @param strandId - Unique identifier for the strand
558
- * @param type - Strand type: 'o' for open, 'c' for closed
559
- * @param ownerKey - Public key of the authorizing owner
560
- * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
561
- * with the owner's private key, returning a base64url signature
562
- * @param memberPrivateKey - Optional private key for membership in closed strands
563
- */
564
- async insertStrand(
565
- strandId: string,
566
- type: 'o' | 'c',
567
- ownerKey: string,
568
- signMessage: (message: Uint8Array) => string,
569
- memberPrivateKey?: string
570
- ): Promise<void> {
571
- this.ensureInitialized();
572
- log('Inserting strand: %s (type: %s)', strandId, type);
573
-
574
- // Generate a unique stamp ID using the peer ID for distributed uniqueness
575
- const peerId = this.config.libp2pNode.peerId.toString();
576
- const stampId = generateStampId(peerId);
577
-
578
- // Field order MUST match the schema's Strand `Authorized` verify:
579
- // Id, Type, MemberPrivateKey ('' when null), StampId.
580
- const message = buildAuthorizationMessage([strandId, type, memberPrivateKey ?? '', stampId]);
581
- const signature = signMessage(message);
582
-
583
- // StampId is a real, unique column (single-use anti-replay), no longer a context value.
584
- await this.db!.exec(`
585
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
586
- with context OwnerKey = ?, Signature = ?
587
- values (?, ?, ?, ?)
588
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
589
-
590
- log('Strand inserted: %s', strandId);
591
- }
592
-
593
- /**
594
- * Insert a validation key into the control database using an owner signature.
595
- *
596
- * Mirrors {@link insertStrand}: the owner signs the canonical row-bound
597
- * authorization message over (Key, StampId), and the StampId is persisted as a unique
598
- * column for single-use anti-replay. A `ValidationKey` authorizes verifying strand
599
- * formation disclosures.
600
- *
601
- * @param key - The validation public key to enroll
602
- * @param ownerKey - Public key of the authorizing owner
603
- * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
604
- * with the owner's private key, returning a base64url signature
605
- */
606
- async insertValidationKey(
607
- key: string,
608
- ownerKey: string,
609
- signMessage: (message: Uint8Array) => string
610
- ): Promise<void> {
611
- this.ensureInitialized();
612
- log('Inserting validation key: %s', key);
613
-
614
- const peerId = this.config.libp2pNode.peerId.toString();
615
- const stampId = generateStampId(peerId);
616
-
617
- // Field order MUST match the schema's ValidationKey `Authorized` verify: Key, StampId.
618
- const message = buildAuthorizationMessage([key, stampId]);
619
- const signature = signMessage(message);
620
-
621
- await this.db!.exec(`
622
- insert into CadreControl.ValidationKey (Key, StampId)
623
- with context OwnerKey = ?, Signature = ?
624
- values (?, ?)
625
- `, [ownerKey, signature, key, stampId]);
626
-
627
- log('Validation key inserted: %s', key);
628
- }
629
-
630
- /**
631
- * Insert an owner-signed `FormationInvite` (open invitation token).
632
- *
633
- * The invite is the on-network record that later authorizes an
634
- * owner-signature-FREE `Strand` creation: an invited cadre peer redeems it
635
- * by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
636
- * which satisfies the consent branch of `Strand.Authorized`.
637
- *
638
- * Like {@link insertStrand}/{@link insertValidationKey}, the owner signs the
639
- * canonical row-bound authorization message (see {@link buildAuthorizationMessage})
640
- * over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
641
- * bare stamp — so the signature is bound to this invite's contents and cannot be
642
- * transplanted onto an attacker-chosen row. The StampId is persisted as a unique
643
- * column for single-use anti-replay. `FormationInvite.AuthorizedAddOrRemove` gates
644
- * both insert and delete; its `coalesce(new, old)` verify binds the NEW row on
645
- * insert and the OLD row on delete.
646
- *
647
- * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
648
- * when set, a responder redeeming this token records a `FormationUsage` against that
649
- * strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
650
- * null `StrandId` (the default) leaves the legacy responder-provisions path in place.
651
- * Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
652
- *
653
- * The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
654
- * because it has a subquery) CHECK sees AFTER column coercion: TotalUses becomes a
655
- * decimal string (`String(totalUses)` ⇔ `cast(new.TotalUses as text)`) and ExpiresAt
656
- * becomes the engine's canonical `PlainDateTime` string — sourced here from
657
- * {@link canonicalDatetime} (a `select datetime(?)` round-trip) rather than a hand-rolled
658
- * ISO formatter, so signer and verifier agree exactly. A null ExpiresAt / TotalUses /
659
- * ValidationUrl signs as `''`, matching the schema's `coalesce(..., '')`.
660
- *
661
- * @param token - Invitation token (the `FormationInvite` primary key)
662
- * @param sAppId - The sApp a redeemed strand will use
663
- * @param ownerKey - Public key of the authorizing owner
664
- * @param signMessage - ed25519-signs the raw message bytes (no pre-hash),
665
- * returning a base64url signature — the same callback shape {@link insertStrand} uses
666
- * @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
667
- * `strandId` (bind to a pre-existing host strand for provision-then-record)
668
- */
669
- async insertFormationInvite(
670
- token: string,
671
- sAppId: string,
672
- ownerKey: string,
673
- signMessage: (message: Uint8Array) => string,
674
- options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
675
- ): Promise<void> {
676
- this.ensureInitialized();
677
- log('Inserting formation invite: %s', token);
678
-
679
- const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
680
-
681
- // Build each bound field exactly as the deferred CHECK sees it post-coercion:
682
- // - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
683
- // the engine so it byte-matches the column's stored/coerced form.
684
- // - TotalUses: decimal string via String(...) (⇔ cast(new.TotalUses as text)), '' when absent.
685
- // - ValidationUrl: the url or '' when absent.
686
- // - StrandId: the host strand id or '' when absent (text column, no coercion).
687
- const expiresAtCanonical = options.expiresAtMs == null
688
- ? null
689
- : await canonicalDatetime(this.db!, options.expiresAtMs);
690
- const expiresAtField = expiresAtCanonical ?? '';
691
- const totalUsesField = options.totalUses == null ? '' : String(options.totalUses);
692
- const validationUrlField = options.validationUrl ?? '';
693
- const strandIdField = options.strandId ?? '';
694
-
695
- // Field order MUST match the schema's FormationInvite `AuthorizedAddOrRemove` verify:
696
- // Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
697
- const message = buildAuthorizationMessage([
698
- token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
699
- ]);
700
- const signature = signMessage(message);
701
-
702
- // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
703
- // signed source-of-truth and the stored value are produced once. StampId is a real,
704
- // unique column (single-use anti-replay), no longer a context value.
705
- await this.db!.exec(`
706
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
707
- with context OwnerKey = ?, Signature = ?
708
- values (?, ?, ?, ?, ?, ?, ?)
709
- `, [
710
- ownerKey, signature,
711
- token, sAppId,
712
- expiresAtCanonical,
713
- options.totalUses ?? null,
714
- options.validationUrl ?? null,
715
- options.strandId ?? null,
716
- stampId,
717
- ]);
718
-
719
- log('Formation invite inserted: %s', token);
720
- }
721
-
722
- /**
723
- * Redeem a `FormationInvite` by inserting the `Strand` row and a matching
724
- * `FormationUsage` row **atomically, in one transaction**.
725
- *
726
- * The two CHECK constraints are mutually circular under immediate evaluation:
727
- * `Strand.Authorized`'s consent branch requires the `FormationUsage` row, while
728
- * `FormationUsage.StrandExists` requires the `Strand` row. Both CHECKs contain
729
- * subqueries, so Quereus auto-defers them to transaction commit — wrapping both
730
- * inserts in a single explicit `begin … commit` lets both deferred CHECKs see
731
- * both rows at commit. The strand is authorised WITHOUT an owner signature
732
- * (the `FormationUsage` branch of `Strand.Authorized`) but still gets a fresh,
733
- * unique `StampId` column to satisfy the not-null/unique anti-replay column.
734
- *
735
- * `UseNumber` is computed as `max(UseNumber)+1` for the token (the `Monotonic`
736
- * constraint); callers redeeming concurrently against the same token must
737
- * serialise, since the next use number is read before the insert.
738
- */
739
- async redeemInvitation(params: {
740
- token: string;
741
- strandId: string;
742
- type?: 'o' | 'c';
743
- memberPrivateKey?: string;
744
- disclosure?: string;
745
- peerId?: string;
746
- peerSignature?: string;
747
- nowMs?: number;
748
- validationKey?: string;
749
- validationSignature?: string;
750
- }): Promise<void> {
751
- this.ensureInitialized();
752
- const {
753
- token, strandId, type = 'o',
754
- memberPrivateKey, disclosure = '',
755
- peerId, peerSignature,
756
- nowMs, validationKey, validationSignature,
757
- } = params;
758
- log('Redeeming invitation %s -> strand %s', token, strandId);
759
-
760
- const localPeerId = this.config.libp2pNode.peerId.toString();
761
- const strandStampId = generateStampId(localPeerId);
762
- const useNumber = await this.nextUseNumber(token);
763
-
764
- await this.db!.beginTransaction();
765
- try {
766
- // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
767
- // still carrying a fresh unique StampId for the anti-replay column.
768
- await this.db!.exec(`
769
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
770
- with context OwnerKey = null, Signature = null
771
- values (?, ?, ?, ?)
772
- `, [strandId, type, memberPrivateKey ?? null, strandStampId]);
773
-
774
- // 2. FormationUsage row — authorised by the matching FormationInvite.
775
- await this.execFormationUsageInsert({
776
- token, useNumber, disclosure, strandId,
777
- peerId: peerId ?? localPeerId, peerSignature: peerSignature ?? null, nowMs: nowMs ?? Date.now(),
778
- validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
779
- });
780
-
781
- await this.db!.commit();
782
- log('Redeemed invitation %s -> strand %s (use #%d)', token, strandId, useNumber);
783
- } catch (error) {
784
- // A failed commit() already tears down the transaction, so rollback() would
785
- // throw "No transaction active" and mask the real cause — swallow only that.
786
- try {
787
- await this.db!.rollback();
788
- } catch (rollbackError) {
789
- log('Rollback after redemption failure was a no-op: %s', rollbackError);
790
- }
791
- throw error;
792
- }
793
- }
794
-
795
- /**
796
- * Record a `FormationUsage` against an **already-existing** `Strand` (no strand
797
- * insert). This is the redemption path when the strand was provisioned
798
- * separately (e.g. owner-signed) and the consent record is added after the
799
- * fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
800
- * is satisfied by the pre-existing committed strand row. Returns the assigned
801
- * `UseNumber`.
802
- *
803
- * Use {@link redeemInvitation} instead when the strand must be created by
804
- * consent atomically with the usage.
805
- */
806
- async recordFormationUsage(params: {
807
- token: string;
808
- strandId: string;
809
- disclosure?: string;
810
- peerId?: string;
811
- peerSignature?: string;
812
- nowMs?: number;
813
- validationKey?: string;
814
- validationSignature?: string;
815
- }): Promise<number> {
816
- this.ensureInitialized();
817
- const {
818
- token, strandId, disclosure = '',
819
- peerId, peerSignature, nowMs, validationKey, validationSignature,
820
- } = params;
821
-
822
- const localPeerId = this.config.libp2pNode.peerId.toString();
823
- const useNumber = await this.nextUseNumber(token);
824
-
825
- await this.execFormationUsageInsert({
826
- token, useNumber, disclosure, strandId,
827
- peerId: peerId ?? localPeerId, peerSignature: peerSignature ?? null, nowMs: nowMs ?? Date.now(),
828
- validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
829
- });
830
-
831
- log('Recorded formation usage: token=%s strand=%s (use #%d)', token, strandId, useNumber);
832
- return useNumber;
833
- }
834
-
835
- /** Parameterised `FormationUsage` insert shared by redeem + record paths. */
836
- private async execFormationUsageInsert(opts: {
837
- token: string;
838
- useNumber: number;
839
- disclosure: string;
840
- strandId: string;
841
- peerId: string;
842
- peerSignature: string | null;
843
- nowMs: number;
844
- validationKey: string | null;
845
- validationSignature: string | null;
846
- }): Promise<void> {
847
- // Derive `context.Now` through the same `canonicalDatetime` transform that
848
- // produced the stored `ExpiresAt`, so the deferred CHECK's `FI.ExpiresAt >
849
- // context.Now` compares two byte-identical engine-`datetime` strings. The
850
- // previous `new Date(nowMs).toISOString()` form differed only by a trailing
851
- // `.000Z` (the engine `datetime()` separator is `T`, not a space), which never
852
- // flipped the strict `>` against a second-granular `ExpiresAt` — so this is a
853
- // robustness/consistency change, matching the strand layer's `consumeInvite`,
854
- // not a fix for an observable mis-ordering.
855
- const nowCanonical = await canonicalDatetime(this.db!, opts.nowMs);
856
- await this.db!.exec(`
857
- insert into CadreControl.FormationUsage (Token, UseNumber, Disclosure, StrandId)
858
- with context PeerId = ?, PeerSignature = ?, Now = ?, ValidationKey = ?, ValidationSignature = ?
859
- values (?, ?, ?, ?)
860
- `, [
861
- opts.peerId, opts.peerSignature, nowCanonical,
862
- opts.validationKey, opts.validationSignature,
863
- opts.token, opts.useNumber, opts.disclosure, opts.strandId,
864
- ]);
865
- }
866
-
867
- /**
868
- * Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
869
- * the parsed epoch-ms of the stored `datetime` (null when the invite never
870
- * expires); the caller compares it against the wall clock for freshness.
871
- */
872
- async queryFormationInvite(token: string): Promise<{
873
- token: string;
874
- sAppId: string;
875
- expiresAtMs: number | null;
876
- totalUses: number | null;
877
- validationUrl: string | null;
878
- strandId: string | null;
879
- } | null> {
880
- this.ensureInitialized();
881
- for await (const row of this.db!.eval(
882
- 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?',
883
- [token]
884
- )) {
885
- const expiresAt = row.ExpiresAt as string | number | null;
886
- const expiresAtMs = expiresAt === null || expiresAt === undefined
887
- ? null
888
- : parseStoredDatetimeMs(expiresAt);
889
- return {
890
- token: row.Token as string,
891
- sAppId: row.sAppId as string,
892
- expiresAtMs: expiresAtMs !== null && Number.isNaN(expiresAtMs) ? null : expiresAtMs,
893
- totalUses: (row.TotalUses as number | null) ?? null,
894
- validationUrl: (row.ValidationUrl as string | null) ?? null,
895
- strandId: (row.StrandId as string | null) ?? null,
896
- };
897
- }
898
- return null;
899
- }
900
-
901
- /**
902
- * Count `FormationUsage` rows recorded against a token (uses consumed so far).
903
- */
904
- async countFormationUsage(token: string): Promise<number> {
905
- this.ensureInitialized();
906
- for await (const row of this.db!.eval(
907
- 'select count(1) as Count from CadreControl.FormationUsage where Token = ?',
908
- [token]
909
- )) {
910
- return (row.Count as number) ?? 0;
911
- }
912
- return 0;
913
- }
914
-
915
- /** Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint. */
916
- private async nextUseNumber(token: string): Promise<number> {
917
- for await (const row of this.db!.eval(
918
- 'select coalesce(max(UseNumber), 0) as MaxUse from CadreControl.FormationUsage where Token = ?',
919
- [token]
920
- )) {
921
- return (row.MaxUse as number) + 1;
922
- }
923
- return 1;
924
- }
925
-
926
- /**
927
- * Close the database and cleanup resources
928
- */
929
- async close(): Promise<void> {
930
- if (this.collectionFactory) {
931
- await this.collectionFactory.shutdown();
932
- this.collectionFactory = null;
933
- }
934
- if (this.db) {
935
- void this.db.close();
936
- this.db = null;
937
- }
938
- this.initialized = false;
939
- log('ControlDatabase closed');
940
- }
941
-
942
- private ensureInitialized(): void {
943
- if (!this.initialized || !this.db) {
944
- throw new Error('ControlDatabase not initialized. Call initialize() first.');
945
- }
946
- }
947
- }
948
-
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { Database, registerPlugin } from '@quereus/quereus';
4
+ import type { VTablePluginInfo, FunctionPluginInfo, SqlParameters } from '@quereus/quereus';
5
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
6
+ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
+ import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
+ import type { Libp2p } from '@libp2p/interface';
9
+ import type { IRepo } from '@optimystic/db-core';
10
+ import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
11
+ import { CONTROL_SCHEMA } from './control-schema.js';
12
+ import { canonicalDatetime } from './canonical-datetime.js';
13
+ import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
14
+ import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
15
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
16
+ import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
17
+ import type { ControlWriteRetryOptions } from './control-write-retry.js';
18
+
19
+ export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
20
+
21
+ const log = debug('sereus:cadre:control-db');
22
+ const timing = debug('sereus:cadre:timing');
23
+
24
+ /**
25
+ * Generate a unique stamp ID for transaction authorization.
26
+ * Format: 32 bytes base64url encoded
27
+ * - First 16 bytes: SHA-256 hash of peer ID (for distributed uniqueness)
28
+ * - Last 16 bytes: Random bytes (for collision resistance)
29
+ */
30
+ export function generateStampId(peerId: string): string {
31
+ // Hash the peer ID and get first 16 bytes (128 bits). A purely local ID
32
+ // generator: never signed/verified against SQL, so the framed single-field
33
+ // digest is fine (the changed framing is not cross-checked anywhere).
34
+ const peerIdHash = digest([peerId], 'sha256', 'bytes') as Uint8Array;
35
+ const peerIdHashPart = peerIdHash.slice(0, 16);
36
+
37
+ // Generate 16 random bytes
38
+ const randomPart = randomBytes(128, 'bytes') as Uint8Array;
39
+
40
+ // Combine peer ID hash and random bytes
41
+ const combined = new Uint8Array(32);
42
+ combined.set(peerIdHashPart, 0);
43
+ combined.set(randomPart, 16);
44
+
45
+ // Convert to base64url
46
+ return uint8ArrayToString(combined, 'base64url');
47
+ }
48
+
49
+ /**
50
+ * A `FormationUsage` was to be recorded against a host `Strand` row that is not
51
+ * present locally.
52
+ *
53
+ * Consent is bound to the strand's one-off `StampId` (see the consent branch of
54
+ * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
55
+ * The ordinary "host strand has not converged on this responder yet" case is caught
56
+ * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
57
+ * reaching here means the row vanished between that check and the write, which is a
58
+ * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
59
+ * CHECK at commit (which would drop the write with a far less legible error).
60
+ */
61
+ export class MissingHostStrandError extends Error {
62
+ constructor(readonly strandId: string, readonly token: string) {
63
+ super(`Cannot record formation usage for token ${token}: host strand ${strandId} is not present`);
64
+ this.name = 'MissingHostStrandError';
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A formation write was abandoned because the caller's `AbortSignal` fired BEFORE the
70
+ * `FormationUsage` insert was issued — the invite's single use is NOT spent.
71
+ *
72
+ * Thrown by the formation write paths ({@link ControlDatabase.recordFormationUsage},
73
+ * {@link ControlDatabase.redeemInvitation}) and by `ControlFormationUsageRecorder`'s
74
+ * pre-approval checks when the responder's provisioning budget expires while the work is
75
+ * still queued (e.g. behind the write lock) or still asking the approval hook. Never
76
+ * thrown once the insert has been issued: a half-issued write must be allowed to land,
77
+ * and the formation listener's settle grace then adopts it as a successful join. The
78
+ * manager rethrows this error instead of mapping it to a retryable conflict — the
79
+ * listener's timeout path owns the reply.
80
+ */
81
+ export class FormationAbortedError extends Error {
82
+ constructor(
83
+ readonly token: string,
84
+ operation: string,
85
+ options?: { cause?: unknown }
86
+ ) {
87
+ super(`Formation ${operation} for token ${token} was aborted before its write was issued`, options);
88
+ this.name = 'FormationAbortedError';
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
94
+ * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
95
+ * seat.
96
+ *
97
+ * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
98
+ * the write, inside {@link ControlDatabase.redeemInvitation} /
99
+ * {@link ControlDatabase.recordFormationUsage} — and off nothing else. It exists because
100
+ * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
101
+ * schema's own count-based cap clause, which the manager reports as a retryable
102
+ * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
103
+ * Two redemptions of one token racing on the SAME node serialize behind the local write
104
+ * queue, so the loser reads the winner's committed row and is refused here by name —
105
+ * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
106
+ * ran before either redemption wrote anything.
107
+ */
108
+ export class InvitationExhaustedError extends Error {
109
+ constructor(
110
+ readonly token: string,
111
+ readonly usesRecorded: number,
112
+ readonly totalUses: number,
113
+ ) {
114
+ super(
115
+ `Invitation ${token} is exhausted: ${usesRecorded} of ${totalUses} use(s) already recorded`
116
+ );
117
+ this.name = 'InvitationExhaustedError';
118
+ }
119
+ }
120
+
121
+ /**
122
+ * What a recorded `FormationUsage` row came out as.
123
+ *
124
+ * `usageStampId` is echoed back so a caller that obtained an approver sign-off can prove the
125
+ * nonce it signed over is the one that landed — the two must match or
126
+ * `FormationUsage.Authorized` rejects the row (see {@link formationVouchMessage}).
127
+ */
128
+ export interface FormationUsageResult {
129
+ /** The single-use nonce written to `UsageStampId` (the row's primary key) — the caller's, or a freshly minted one. */
130
+ usageStampId: string;
131
+ }
132
+
133
+ /**
134
+ * Parse a stored Quereus `datetime` value into epoch milliseconds.
135
+ *
136
+ * Quereus canonicalises a `datetime` column to a bare UTC `PlainDateTime` string
137
+ * (e.g. `2026-06-04T12:34:56`, no `Z`). JS `Date` reads a timezone-less
138
+ * date-time as LOCAL, so we append `Z` when no offset/zone is present to keep the
139
+ * value anchored to UTC. Numbers (already epoch ms) pass through.
140
+ */
141
+ function parseStoredDatetimeMs(value: string | number): number {
142
+ if (typeof value === 'number') return value;
143
+ const hasZone = /[zZ]$|[+-]\d\d:?\d\d$/.test(value);
144
+ return new Date(hasZone ? value : `${value}Z`).getTime();
145
+ }
146
+
147
+ /**
148
+ * Parse a nullable stored `datetime` column into epoch ms, mapping both "absent"
149
+ * and "unparseable" to null — i.e. "no expiry". Shared by every `ExpiresAt`
150
+ * reader so the NaN guard cannot drift between them.
151
+ */
152
+ function parseNullableStoredDatetimeMs(value: string | number | null | undefined): number | null {
153
+ if (value === null || value === undefined) return null;
154
+ const parsed = parseStoredDatetimeMs(value);
155
+ return Number.isNaN(parsed) ? null : parsed;
156
+ }
157
+
158
+ /**
159
+ * Build the canonical authorization message that owner signatures are bound to.
160
+ *
161
+ * The message is a SINGLE framed SHA-256 digest over the ordered field vector from
162
+ * {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
163
+ * encoding): two fixed literals — the domain tag naming the table rule and the action
164
+ * tag — followed by the row fields in the schema's fixed order, with the single-use
165
+ * StampId as the final field where the table has one:
166
+ *
167
+ * message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
168
+ *
169
+ * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
170
+ * verify the identical bytes with one variadic call
171
+ * (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
172
+ * SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
173
+ * base64url input encoding decodes back to those raw bytes — so signer and verifier
174
+ * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
175
+ * `cast(... as text)` / `coalesce(...,'')`; the TS args are strings), so the per-field
176
+ * type tags agree. Binding the row contents closes captured-stamp replay; the leading
177
+ * domain/action tags scope the signature to ONE table rule, so an approval minted for
178
+ * one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
179
+ * longer double as an OwnerKey enrollment). Single source of truth: every signed writer
180
+ * (and every test/harness signer) MUST build the message through this function with the
181
+ * schema's tags and field order, or `verify` will reject the row.
182
+ */
183
+ export function buildAuthorizationMessage(
184
+ domain: ControlDomain,
185
+ action: ControlAction,
186
+ rowFields: string[],
187
+ ): Uint8Array {
188
+ return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes') as Uint8Array;
189
+ }
190
+
191
+ /**
192
+ * The exact bytes an outside approver signs to authorize ONE redemption of a
193
+ * `ValidationUrl`-bearing `FormationInvite` — the TS mirror of the `'vouch'` digest in
194
+ * `FormationUsage.Authorized`, in the schema's field order.
195
+ *
196
+ * The approval covers the whole redemption: the invitation (`token`), the single-use nonce
197
+ * the JOINING peer minted for THIS redemption (`usageStampId`), the network being joined
198
+ * (`strandId`), the joining peer's own ed25519 public key (`peerKey`), and the disclosure
199
+ * text. That makes it non-transferable — an approval cannot be re-presented for another use
200
+ * of the same invitation, another network, or another joiner — and the nonce's `unique`
201
+ * column makes a verbatim re-presentation a duplicate-row rejection. The joiner mints the
202
+ * nonce ({@link generateStampId}) and sends it in its contact message; sign these bytes over
203
+ * that same nonce, then pass BOTH the nonce and the signature to
204
+ * {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
205
+ * signing one nonce and inserting another fails the CHECK.
206
+ */
207
+ export function formationVouchMessage(fields: {
208
+ token: string;
209
+ usageStampId: string;
210
+ strandId: string;
211
+ peerKey: string;
212
+ disclosure: string;
213
+ }): Uint8Array {
214
+ return buildAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
215
+ fields.token, fields.usageStampId, fields.strandId, fields.peerKey, fields.disclosure,
216
+ ]);
217
+ }
218
+
219
+ /**
220
+ * The exact bytes the JOINING peer signs to consent to ONE redemption of a
221
+ * `FormationInvite` — the TS mirror of the `'consent'` digest in the schema's
222
+ * `FormationUsage.PeerConsented` constraint, in its field order.
223
+ *
224
+ * A sibling of {@link formationVouchMessage}, not a replacement: the approver's vouch
225
+ * says the redemption may proceed, this says the joiner itself agreed to it, and the
226
+ * two are signed by different keys over the same nonce so neither can stand in for
227
+ * the other. Verify the result with `peer-authorization.ts`'s
228
+ * `verifyFormationConsent`, whose `formationConsentDigest` is the base64url twin of
229
+ * this vector — the two must stay in lockstep.
230
+ *
231
+ * `strandId` is deliberately absent, unlike its vouch sibling: the joiner cannot know
232
+ * the strand when it signs (a bound invite's host strand arrives only in the result
233
+ * frame; an unbound strand is minted by the responder). The responder cannot
234
+ * substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
235
+ * and an unbound redemption mints a fresh strand, so there is no victim to name.
236
+ */
237
+ export function formationConsentMessage(fields: {
238
+ token: string;
239
+ usageStampId: string;
240
+ peerKey: string;
241
+ disclosure: string;
242
+ }): Uint8Array {
243
+ return buildAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
244
+ fields.token, fields.usageStampId, fields.peerKey, fields.disclosure,
245
+ ]);
246
+ }
247
+
248
+ /**
249
+ * Minimal interface for the CollectionFactory returned by the optimystic plugin.
250
+ * We only need the methods we actually use.
251
+ */
252
+ interface CollectionFactory {
253
+ registerLibp2pNode(networkName: string, node: Libp2p, coordinatedRepo: IRepo): void;
254
+ shutdown(): Promise<void>;
255
+ }
256
+
257
+ /** Result of registering the optimystic plugin */
258
+ interface OptimysticPluginResult {
259
+ collectionFactory: CollectionFactory;
260
+ vtables: VTablePluginInfo[];
261
+ functions: FunctionPluginInfo[];
262
+ /**
263
+ * Hydrate Quereus's in-memory catalog from persisted optimystic vtab schemas.
264
+ * Must run BEFORE applying CONTROL_SCHEMA on a warm restart so the declarative
265
+ * diff sees the existing tables and skips re-emitting CREATE TABLE / CREATE INDEX
266
+ * for each persisted control object. Idempotent; `{ tables: 0, indexes: 0 }` on a
267
+ * cold/in-memory start. Mirrors the strand path's hydrate-before-apply (see
268
+ * compose-strand.ts).
269
+ */
270
+ hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
271
+ [key: string]: unknown;
272
+ }
273
+
274
+ /** Runtime guard for the dynamic-`from` count, over the one table list. */
275
+ const CONTROL_TABLE_SET: ReadonlySet<ControlTable> = new Set<ControlTable>(CONTROL_TABLES);
276
+
277
+ /** Primary-key column of each {@link RevocableTable}. */
278
+ type GuardedKeyColumn = 'Key' | 'Id' | 'PeerId';
279
+
280
+ /**
281
+ * Which column identifies one row of each guarded table — the ONLY place that mapping
282
+ * lives, so a stamp read and the delete that binds to it can never disagree about it.
283
+ * Derived rather than passed: every guarded table has exactly one primary key, so a
284
+ * `(table, keyColumn)` pair on a signature is a mismatch waiting to happen.
285
+ */
286
+ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
287
+ OwnerKey: 'Key',
288
+ CadrePeer: 'PeerId',
289
+ ValidationKey: 'Key',
290
+ Strand: 'Id',
291
+ DeviceToken: 'PeerId',
292
+ };
293
+
294
+ /**
295
+ * Guarded tables a node may reap locally once their tombstone has committed — the
296
+ * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
297
+ * `CadrePeer.AuthorizedDelete` in the schema). `Strand` is deliberately absent: its row
298
+ * carries `MemberPrivateKey`, the party's own membership secret for that network, stored
299
+ * nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any future change).
300
+ * `OwnerKey` has no production removal path and `MinOneOwner` makes an automated
301
+ * owner-key reap a party-bricking hazard.
302
+ */
303
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey'] as const satisfies readonly RevocableTable[];
304
+ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
305
+
306
+ /**
307
+ * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
308
+ * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
309
+ * naming an excluded table (`Strand`, `OwnerKey`) is skipped silently — `Strand`
310
+ * tombstones are the common case in a party that has unpublished a strand, so a log line
311
+ * per skip would be per-pass noise.
312
+ */
313
+ const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(REAPABLE_TABLES);
314
+ const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
315
+
316
+ /**
317
+ * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
318
+ *
319
+ * The one hook the party-membership snapshot a node admits control-DB traffic
320
+ * against ({@link CadreNode.refreshMembershipGate}) hangs off, so that snapshot
321
+ * is refreshed by the WRITE rather than by whoever remembered to ask. `reason`
322
+ * only labels the log line. Must not reject (the notifier swallows and logs
323
+ * anyway — a committed write never fails because a snapshot refresh did).
324
+ */
325
+ export type MembershipChangeListener = (reason: string) => Promise<void>;
326
+
327
+ /**
328
+ * Identity of the tombstone a committed guarded delete wrote — a
329
+ * {@link RevocationRow} minus its `ReissuedAt` counter, which a fresh tombstone
330
+ * always seats at 0 (`FreshTombstone`) and so carries no information.
331
+ */
332
+ export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
333
+
334
+ /**
335
+ * Notified after a guarded-table delete — and the `CadreControl.Revocation`
336
+ * tombstone it writes in the same transaction — has COMMITTED.
337
+ *
338
+ * The seam the write-while-alone re-replication queue hangs off
339
+ * ({@link CadreNode.noteGuardedDelete}): one listener covers all four guarded
340
+ * tables (`CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`) because every
341
+ * owner delete funnels through {@link deleteGuardedRow}. Synchronous — the
342
+ * handler only records the tombstone's identity; it must not throw (the notifier
343
+ * swallows and logs anyway — a committed delete never fails because bookkeeping did).
344
+ */
345
+ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
346
+
347
+ export interface ControlDatabaseConfig {
348
+ /** Party ID for the control network */
349
+ partyId: string;
350
+ /**
351
+ * Optional path to the control schema file.
352
+ * If not provided, uses the embedded schema for cross-platform compatibility.
353
+ * Only use this if you need to override the default schema (e.g., for testing).
354
+ */
355
+ schemaPath?: string;
356
+ /** Libp2p node for the control network (injected) */
357
+ libp2pNode: Libp2p;
358
+ /** Coordinated repo from the libp2p node */
359
+ coordinatedRepo: IRepo;
360
+ }
361
+
362
+ /**
363
+ * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
364
+ * It provides typed query methods for accessing control network data.
365
+ */
366
+ export class ControlDatabase {
367
+ private db: Database | null = null;
368
+ private collectionFactory: CollectionFactory | null = null;
369
+ private readonly config: ControlDatabaseConfig;
370
+ private initialized = false;
371
+ private membershipListener: MembershipChangeListener | null = null;
372
+ private guardedDeleteListener: GuardedDeleteListener | null = null;
373
+ /** Tail of the local-write chain — see {@link withWriteLock}. */
374
+ private writeQueue: Promise<unknown> = Promise.resolve();
375
+ /**
376
+ * Pacing seams for {@link lockedWithRetry}. Production leaves this empty (real backoff,
377
+ * real clock); specs inject a recorded `sleep` / fake `now` so no test waits out a real
378
+ * backoff. Applied OVER the call site's policy, so it overrides pacing without a spec
379
+ * having to restate the policy's attempts or classifier.
380
+ */
381
+ private controlWriteRetryPacing: ControlWriteRetryOptions = {};
382
+
383
+ constructor(config: ControlDatabaseConfig) {
384
+ this.config = config;
385
+ }
386
+
387
+ /**
388
+ * Initialize the database - load schema and register plugins
389
+ */
390
+ async initialize(): Promise<void> {
391
+ if (this.initialized) {
392
+ log('ControlDatabase already initialized');
393
+ return;
394
+ }
395
+
396
+ log('Initializing ControlDatabase for party: %s', this.config.partyId);
397
+
398
+ // Create database instance
399
+ this.db = new Database();
400
+
401
+ // Register crypto plugin (provides digest, sign, verify functions)
402
+ let t0 = performance.now();
403
+ await registerPlugin(this.db, cryptoPlugin);
404
+ timing('[controlDb] cryptoPlugin: %dms', Math.round(performance.now() - t0));
405
+ log('Registered crypto plugin');
406
+
407
+ // Register optimystic plugin with network transactor as default
408
+ t0 = performance.now();
409
+ const networkName = `control-${this.config.partyId}`;
410
+ const pluginResult = optimysticPlugin(this.db, {
411
+ default_transactor: 'network',
412
+ default_key_network: 'libp2p',
413
+ default_network_name: networkName,
414
+ enable_cache: true,
415
+ }) as unknown as OptimysticPluginResult;
416
+
417
+ // Register vtables and functions manually since we need access to collectionFactory
418
+ for (const vtable of pluginResult.vtables) {
419
+ this.db.registerModule(vtable.name, vtable.module, vtable.auxData);
420
+ }
421
+ for (const func of pluginResult.functions) {
422
+ this.db.registerFunction(func.schema);
423
+ }
424
+ timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
425
+
426
+ this.collectionFactory = pluginResult.collectionFactory;
427
+
428
+ // Inject the libp2p node into the collection factory
429
+ t0 = performance.now();
430
+ this.collectionFactory.registerLibp2pNode(
431
+ networkName,
432
+ this.config.libp2pNode,
433
+ this.config.coordinatedRepo
434
+ );
435
+ timing('[controlDb] registerLibp2pNode: %dms', Math.round(performance.now() - t0));
436
+ log('Registered libp2p node with collection factory');
437
+
438
+ // Network-back the control tables. CONTROL_SCHEMA's `declare schema CadreControl
439
+ // { table ... }` tables carry NO per-table `using optimystic(...)`, so storage is
440
+ // chosen by the database's DEFAULT vtab. Routing the default to optimystic (with
441
+ // the network transactor + this party's control network) is what makes a control
442
+ // write replicate peer-to-peer — exactly what connectToStrand does for strand
443
+ // tables (compose-strand.ts). Without these two calls the tables fall back to
444
+ // Quereus's in-memory vtab and never converge across the cadre.
445
+ this.db.setDefaultVtabName('optimystic');
446
+ this.db.setDefaultVtabArgs({
447
+ networkName,
448
+ transactor: 'network',
449
+ keyNetwork: 'libp2p',
450
+ });
451
+ log('Set default vtab to optimystic (networkName=%s, transactor=network)', networkName);
452
+
453
+ // Hydrate Quereus's catalog from any persisted optimystic vtab schemas BEFORE
454
+ // applying CONTROL_SCHEMA, so a warm restart with persistent storage diffs the
455
+ // control DDL against the already-present tables and re-emits nothing — the same
456
+ // warm-start regression connectToStrand's hydrate-before-apply guards against.
457
+ // No-op on a cold / in-memory start (`{ tables: 0, indexes: 0 }`).
458
+ t0 = performance.now();
459
+ const hydrated = await pluginResult.hydrate(this.db);
460
+ timing('[controlDb] hydrate: %dms (tables=%d, indexes=%d)',
461
+ Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
462
+ log('Hydrated control catalog (tables=%d, indexes=%d)', hydrated.tables, hydrated.indexes);
463
+
464
+ // Load and execute the schema
465
+ // NOTE: this is where a slow launch is felt. Duration is (raw-storage operations
466
+ // issued) × per-operation storage latency: ~1ms/op on an idle machine, but
467
+ // 50-90ms/op on a loaded disk or a phone's flash under launch contention. A cold
468
+ // start now reaches the backend 172 times (8 tables + 1 index, 21 distinct blocks
469
+ // — dominated by its 130 genuine writes), a warm restart 52, because cadre-core
470
+ // wraps every embedder storage in `@optimystic/db-p2p`'s write-through cache
471
+ // (@serfab/quereus-plugin-sereus's cached-storage.ts). Uncached the same start issued ~2000 operations — the
472
+ // upstream re-read amplification measured in
473
+ // tickets/complete/optimystic-block-read-amplification-on-control-start.md (and
474
+ // its follow-up ...-schema-catalog-reread-per-write-blows-storage-budgets.md),
475
+ // which also rules out the retry policy and cluster deadlines. The counts are
476
+ // pinned by packages/cadre-core/test/control-start-storage-op-budget.spec.ts; if
477
+ // they grow — e.g. the cache leaves this path — that spec fails before anyone
478
+ // has to debug a launch.
479
+ t0 = performance.now();
480
+ await this.loadSchema();
481
+ timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
482
+
483
+ this.initialized = true;
484
+ log('ControlDatabase initialized successfully');
485
+ }
486
+
487
+ private async loadSchema(): Promise<void> {
488
+ let schemaContent: string;
489
+
490
+ if (this.config.schemaPath) {
491
+ // Load from file if explicitly provided (for testing or custom schemas)
492
+ // This only works in Node.js environments
493
+ log('Loading schema from file: %s', this.config.schemaPath);
494
+
495
+ // Check if we're in a Node.js environment
496
+ if (typeof process !== 'undefined' && process.versions?.node) {
497
+ try {
498
+ // Use require to conditionally load fs only in Node.js.
499
+ // This won't be bundled by React Native's Metro bundler — a dynamic
500
+ // import() would be statically picked up by Metro, so require is
501
+ // intentional here (cross-platform constraint, not lazy CommonJS).
502
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
503
+ const fs = require('fs/promises');
504
+ schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
505
+ } catch (error) {
506
+ throw new Error(
507
+ `Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
508
+ { cause: error }
509
+ );
510
+ }
511
+ } else {
512
+ throw new Error(
513
+ 'Loading schema from file is not supported in React Native. ' +
514
+ 'Remove the schemaPath option to use the embedded schema instead.'
515
+ );
516
+ }
517
+ } else {
518
+ // Use embedded schema for cross-platform compatibility
519
+ log('Using embedded control schema');
520
+ schemaContent = CONTROL_SCHEMA;
521
+ }
522
+
523
+ // Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
524
+ // retry every other control write does ({@link lockedWithRetry}). Every CadreControl
525
+ // table is optimystic-backed (the default vtab was routed to optimystic just above),
526
+ // so each `create table` needs a super-majority of the party's peers to answer. On a
527
+ // node joining a party that already has two live members a single unanswered peer
528
+ // during any one create otherwise kills startup outright.
529
+ //
530
+ // Re-running the WHOLE `exec` after a partial failure is safe, and this is the
531
+ // non-obvious part:
532
+ //
533
+ // - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
534
+ // the declared schema against it, and only emits DDL for what is missing — which is
535
+ // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
536
+ // Tables that already landed generate no statements on the second pass.
537
+ // - a failed `create table` leaves the catalog CLEAN. `SchemaManager.createTable` calls
538
+ // the module's `create` first and only registers the table on success, so the table
539
+ // that failed is not in the catalog, and neither are the ones after it.
540
+ //
541
+ // Together: attempt 2 re-emits exactly the failed table and its successors. No
542
+ // `if not exists` juggling, no per-table loop, no statement splitting.
543
+ //
544
+ // Only the "cohort did not answer, nothing committed" class is retried; the classifier
545
+ // vetoes indeterminate commits and does not match `Missing block`, which is a durable
546
+ // convergence fault a retry cannot heal (tracked separately) and must keep propagating.
547
+ //
548
+ // This is the ONE call site on a non-default policy: the re-run safety argued above is
549
+ // also what lets schema init absorb optimystic's self-coordination grace refusal, which
550
+ // is unsafe for control writes in general. Why, on
551
+ // `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
552
+ await this.lockedWithRetry(() => this.db!.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
553
+ log('Schema loaded and executed');
554
+ }
555
+
556
+ /**
557
+ * Query all strands from the control database
558
+ */
559
+ async queryStrands(): Promise<StrandRow[]> {
560
+ this.ensureInitialized();
561
+ const results: StrandRow[] = [];
562
+ for await (const row of this.db!.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand')) {
563
+ results.push({
564
+ Id: row.Id as string,
565
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
566
+ Type: row.Type as 'o' | 'c',
567
+ });
568
+ }
569
+ return results;
570
+ }
571
+
572
+ /**
573
+ * Read a single strand row by id, or null when absent. Single-row sibling of
574
+ * {@link queryStrands}; the responder uses it to read a host strand's
575
+ * `MemberPrivateKey` (the closed-strand read-gating secret) for delivery to a
576
+ * validated invitee during provision-then-record formation.
577
+ */
578
+ async queryStrand(strandId: string): Promise<StrandRow | null> {
579
+ this.ensureInitialized();
580
+ for await (const row of this.db!.eval(
581
+ 'select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?',
582
+ [strandId]
583
+ )) {
584
+ return {
585
+ Id: row.Id as string,
586
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
587
+ Type: row.Type as 'o' | 'c',
588
+ };
589
+ }
590
+ return null;
591
+ }
592
+
593
+ /**
594
+ * Count rows in a CadreControl table as seen by THIS database instance.
595
+ *
596
+ * `table` is validated against {@link CONTROL_TABLE_SET} before it is interpolated
597
+ * into the `from` clause: the names are not user input, but the check keeps the
598
+ * dynamic query off the injection surface and fails loudly on a typo instead of
599
+ * emitting a malformed statement. The count reflects only the rows this node's
600
+ * control DB has converged on — in the integration harness that is the owner
601
+ * node (one ControlDatabase per party), i.e. the authoritative control-network
602
+ * view, not a per-drone convergence guarantee.
603
+ */
604
+ async countRows(table: ControlTable): Promise<number> {
605
+ this.ensureInitialized();
606
+ if (!CONTROL_TABLE_SET.has(table)) {
607
+ throw new Error(`Unknown CadreControl table: ${table}`);
608
+ }
609
+ for await (const row of this.db!.eval(`select count(1) as Count from CadreControl.${table}`)) {
610
+ return (row.Count as number) ?? 0;
611
+ }
612
+ return 0;
613
+ }
614
+
615
+ /**
616
+ * Get the underlying database for advanced queries
617
+ */
618
+ getDatabase(): Database {
619
+ this.ensureInitialized();
620
+ return this.db!;
621
+ }
622
+
623
+ /**
624
+ * Check whether any owner key exists in the control database.
625
+ * Used to decide whether a fresh-party genesis insert is required.
626
+ */
627
+ async hasOwnerKey(): Promise<boolean> {
628
+ this.ensureInitialized();
629
+ for await (const row of this.db!.eval('select count(1) as Count from CadreControl.OwnerKey')) {
630
+ return (row.Count as number) > 0;
631
+ }
632
+ return false;
633
+ }
634
+
635
+ /**
636
+ * Idempotent genesis: insert `key` as the founding owner key only when
637
+ * the party has none yet. Returns true if it inserted, false if an owner
638
+ * key already existed (so a repeat `--owner` start is a no-op).
639
+ */
640
+ async ensureOwnerKey(key: string): Promise<boolean> {
641
+ this.ensureInitialized();
642
+ const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
643
+ if (await this.hasOwnerKey()) {
644
+ log('Owner key already present; skipping genesis insert');
645
+ return false;
646
+ }
647
+ await this.insertOwnerKey(trimmed);
648
+ return true;
649
+ }
650
+
651
+ /**
652
+ * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
653
+ *
654
+ * This is the steady-state trust anchor for seeds: a seed's signer key is
655
+ * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
656
+ * also the owner-identity source for `queryPeers`, decoupling owner
657
+ * status from the libp2p transport peer ID.
658
+ */
659
+ async getOwnerKeys(): Promise<Set<string>> {
660
+ this.ensureInitialized();
661
+ const keys = new Set<string>();
662
+ for await (const row of this.db!.eval('select Key from CadreControl.OwnerKey')) {
663
+ keys.add(row.Key as string);
664
+ }
665
+ return keys;
666
+ }
667
+
668
+ /**
669
+ * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
670
+ * Includes the persisted voucher columns, which
671
+ * {@link CadreNode.listAuthorizedMembers} re-checks against its node-local anchor.
672
+ *
673
+ * Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded HERE, so
674
+ * every membership reader inherits the exclusion — a revoked peer is neither
675
+ * authorizable nor addressable (a replayed row planted at a retired stamp must not
676
+ * be RPC'd or handed out as an address). Reads the retired set through
677
+ * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
678
+ * that seam to model the replicated live-row-plus-tombstone merge state.
679
+ *
680
+ * NOTE: runs a second query (the retired-stamp set) per call, and this sits on the
681
+ * membership-gate refresh path; control tables are a handful of rows today, so this
682
+ * is cheap — if they ever grow, fold the exclusion into one statement or cache the
683
+ * retired-stamp set.
684
+ *
685
+ * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
686
+ * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
687
+ * which feed the node rows directly. Nothing enforces that the copies stay true, so a
688
+ * change to the filter's SHAPE here (what it keys on, which table it consults) must be
689
+ * carried into both, or those suites keep passing against a contract the database no
690
+ * longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
691
+ * second dimension, give the fakes a shared fixture or a contract test instead.
692
+ */
693
+ async queryCadrePeers(): Promise<CadrePeerRow[]> {
694
+ this.ensureInitialized();
695
+ const revoked = await this.queryRevokedStamps('CadrePeer');
696
+ const rows: CadrePeerRow[] = [];
697
+ for await (const row of this.db!.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
698
+ const stampId = (row.StampId as string | null) ?? null;
699
+ if (stampId !== null && revoked.has(stampId)) {
700
+ continue;
701
+ }
702
+ rows.push({
703
+ peerId: row.PeerId as string,
704
+ multiaddr: (row.Multiaddr as string | null) ?? null,
705
+ stampId,
706
+ vouchOwner: (row.VouchOwner as string | null) ?? null,
707
+ vouchSig: (row.VouchSig as string | null) ?? null,
708
+ });
709
+ }
710
+ return rows;
711
+ }
712
+
713
+ /**
714
+ * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
715
+ * set a `ValidationUrl` redemption's approval signature must be signed by (see
716
+ * {@link ControlFormationUsageRecorder}, which checks one key at a time via
717
+ * {@link queryValidationKeyStampId}).
718
+ *
719
+ * Sorted in TypeScript rather than SQL so the order is stable regardless of storage
720
+ * order; the enrolled set is a handful of keys, so the sort is free.
721
+ */
722
+ async queryValidationKeys(): Promise<string[]> {
723
+ this.ensureInitialized();
724
+ const keys: string[] = [];
725
+ for await (const row of this.db!.eval('select Key from CadreControl.ValidationKey')) {
726
+ keys.push(row.Key as string);
727
+ }
728
+ return keys.sort();
729
+ }
730
+
731
+ /**
732
+ * Read one guarded row's single-use `StampId` nonce (null when the row does not
733
+ * exist). Every owner-signed delete / re-touch path must bind its signature to the
734
+ * row's CURRENT nonce, so they all read through here first.
735
+ *
736
+ * Deliberately RAW — no `Revocation` filtering (unlike {@link queryCadrePeers} /
737
+ * {@link queryPeerRecord}): {@link deleteGuardedRow} and the insert-if-absent guard
738
+ * in the `CadrePeer` upsert need to see a physically present row. Filtering here
739
+ * would make an insert-if-absent guard read "absent" for a row that exists and
740
+ * collide on the primary key.
741
+ *
742
+ * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
743
+ * come from closed literal unions — no caller-supplied string can reach the statement
744
+ * (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
745
+ */
746
+ private async queryStampId(
747
+ table: RevocableTable,
748
+ keyValue: string
749
+ ): Promise<string | null> {
750
+ this.ensureInitialized();
751
+ const sql = `select StampId from CadreControl.${table} where ${GUARDED_KEY_COLUMN[table]} = ?`;
752
+ for await (const row of this.db!.eval(sql, [keyValue])) {
753
+ return (row.StampId as string | null) ?? null;
754
+ }
755
+ return null;
756
+ }
757
+
758
+ /**
759
+ * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
760
+ * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
761
+ * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
762
+ * straight to {@link queryStampId}.
763
+ */
764
+ queryCadrePeerStampId(peerId: string): Promise<string | null> {
765
+ return this.queryStampId('CadrePeer', peerId);
766
+ }
767
+
768
+ /**
769
+ * `DeviceToken` stamp nonce. {@link deleteDeviceToken} reads the stamp itself, so this
770
+ * is the reader for callers that need to observe a live row's nonce — asserting a token
771
+ * was seated, or that a removal retired the stamp it named.
772
+ */
773
+ queryDeviceTokenStampId(peerId: string): Promise<string | null> {
774
+ return this.queryStampId('DeviceToken', peerId);
775
+ }
776
+
777
+ /** `Strand` stamp nonce — bound into {@link deleteStrand}'s remove digest. */
778
+ queryStrandStampId(strandId: string): Promise<string | null> {
779
+ return this.queryStampId('Strand', strandId);
780
+ }
781
+
782
+ /** `ValidationKey` stamp nonce — bound into {@link deleteValidationKey}'s remove digest. */
783
+ queryValidationKeyStampId(key: string): Promise<string | null> {
784
+ return this.queryStampId('ValidationKey', key);
785
+ }
786
+
787
+ /**
788
+ * Collect the retired `StampId` nonces recorded in `CadreControl.Revocation` for one
789
+ * {@link RevocableTable}. A stamp
790
+ * lands here when its row is removed ({@link SeedBootstrapService.removePeer},
791
+ * {@link deleteValidationKey}, {@link deleteStrand}), and retirement is permanent —
792
+ * rows are never deleted (only their `ReissuedAt` counter moves, via
793
+ * {@link reissueRevocations}). Read-side mitigation for the write-time race: the
794
+ * schema's `NotRevoked` CHECK only sees locally visible tombstones, so a node that
795
+ * converged on a resurrected row before its tombstone can hold both; readers
796
+ * ({@link queryCadrePeers}, {@link queryPeerRecord},
797
+ * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
798
+ *
799
+ * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
800
+ * gate request while the table only ever grows. Cheap today (a cadre removes peers
801
+ * rarely); if removals ever become routine, cache the set and invalidate it on write.
802
+ */
803
+ async queryRevokedStamps(tableName: RevocableTable): Promise<Set<string>> {
804
+ this.ensureInitialized();
805
+ const stamps = new Set<string>();
806
+ for await (const row of this.db!.eval('select StampId from CadreControl.Revocation where TableName = ?', [tableName])) {
807
+ stamps.add(row.StampId as string);
808
+ }
809
+ return stamps;
810
+ }
811
+
812
+ /**
813
+ * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
814
+ * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
815
+ * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
816
+ * it. Plain scan with no `where`, so the composite-primary-key point-lookup hazard
817
+ * (see the statement comment in {@link reissueRevocations}) does not arise. Unlocked,
818
+ * like every other read.
819
+ */
820
+ async queryRevocations(): Promise<RevocationRow[]> {
821
+ this.ensureInitialized();
822
+ const rows: RevocationRow[] = [];
823
+ for await (const row of this.db!.eval('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation')) {
824
+ rows.push({
825
+ tableName: row.TableName as RevocableTable,
826
+ rowKey: row.RowKey as string,
827
+ stampId: row.StampId as string,
828
+ reissuedAt: (row.ReissuedAt as number | null) ?? 0,
829
+ });
830
+ }
831
+ return rows;
832
+ }
833
+
834
+ /**
835
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
836
+ *
837
+ * Returns null when no row exists, or when the row's `StampId` is retired in
838
+ * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
839
+ * ({@link CadreNode.resolvePeerAddrs}) never hands out a revoked peer's addresses.
840
+ * The stamp is read for that check only and then dropped; the returned
841
+ * {@link PeerAddressRecord} shape is unchanged. Reads the retired set through
842
+ * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
843
+ * that seam (see {@link queryCadrePeers}).
844
+ *
845
+ * Missing/legacy column values are coalesced
846
+ * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
847
+ * verify/freshness gates uniformly reject an unpublished or malformed row.
848
+ * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
849
+ * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
850
+ * the publisher signed.
851
+ */
852
+ async queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null> {
853
+ this.ensureInitialized();
854
+ const revoked = await this.queryRevokedStamps('CadrePeer');
855
+ for await (const row of this.db!.eval(
856
+ 'select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?',
857
+ [peerId]
858
+ )) {
859
+ const stampId = (row.StampId as string | null) ?? null;
860
+ if (stampId !== null && revoked.has(stampId)) {
861
+ return null;
862
+ }
863
+ const multiaddr = (row.Multiaddr as string | null) ?? '';
864
+ return {
865
+ peerId: row.PeerId as string,
866
+ publicKey: (row.PublicKey as string | null) ?? '',
867
+ addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
868
+ updatedAt: (row.UpdatedAt as number | null) ?? 0,
869
+ sig: (row.Sig as string | null) ?? '',
870
+ };
871
+ }
872
+ return null;
873
+ }
874
+
875
+ /**
876
+ * Apply a peer's own self-signed address-record update to an existing row.
877
+ *
878
+ * Authorization is carried entirely by the record: the `Sig` column (verified
879
+ * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
880
+ * the strictly-increasing `UpdatedAt`. No owner key is involved, so this
881
+ * is the refresh path for any member — owner or drone — once its row
882
+ * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
883
+ * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
884
+ *
885
+ * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
886
+ * is not: it only ever touches THIS node's own row (sole caller
887
+ * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
888
+ * of the membership snapshot, so it cannot change that snapshot. It also runs on the
889
+ * periodic self-registration refresh, where a notify would add a recurring membership
890
+ * read for nothing.
891
+ *
892
+ * Its INSERT counterpart (`SeedBootstrapService.insertSelfPeerRecord`) does notify. Not an
893
+ * inconsistency: it shares the one owner-signed insert path with every other member's
894
+ * row, and a self insert happens once at startup, so the wasted refresh is a single read
895
+ * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
896
+ * path is worth exempting.
897
+ */
898
+ async updateSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
899
+ this.ensureInitialized();
900
+ const multiaddr = record.addrs.join(',');
901
+ await this.execWrite(`
902
+ update CadreControl.CadrePeer
903
+ with context OwnerKey = null, Signature = ?
904
+ set Multiaddr = ?, UpdatedAt = ?, Sig = ?
905
+ where PeerId = ?
906
+ `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
907
+ log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
908
+ }
909
+
910
+ /**
911
+ * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
912
+ *
913
+ * Returns null when no row exists. Missing/legacy column values are coalesced to
914
+ * their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
915
+ * gates uniformly reject an unpublished or malformed row. `platform` is returned
916
+ * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
917
+ * the self-signature, which covers the platform field).
918
+ *
919
+ * `stampId` rides along ({@link DeviceTokenRow}) because the resolver must drop a
920
+ * row whose stamp is retired in `CadreControl.Revocation`; it is NOT part of
921
+ * {@link DeviceTokenRecord}, which is the self-signed shape the peer's `Sig` covers.
922
+ */
923
+ async queryDeviceToken(peerId: string): Promise<DeviceTokenRow | null> {
924
+ this.ensureInitialized();
925
+ for await (const row of this.db!.eval(
926
+ 'select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?',
927
+ [peerId]
928
+ )) {
929
+ return {
930
+ peerId: row.PeerId as string,
931
+ platform: (row.Platform as string ?? '') as PushPlatform,
932
+ token: (row.Token as string | null) ?? '',
933
+ updatedAt: (row.UpdatedAt as number | null) ?? 0,
934
+ sig: (row.Sig as string | null) ?? '',
935
+ stampId: (row.StampId as string | null) ?? '',
936
+ };
937
+ }
938
+ return null;
939
+ }
940
+
941
+ /**
942
+ * Apply a peer's own self-signed device-token update to an existing row.
943
+ *
944
+ * Authorization is carried entirely by the record: the `Sig` column (verified by
945
+ * the `DeviceToken.AuthorizedUpdate` self-branch against the stored
946
+ * `CadrePeer.PublicKey`) plus the strictly-increasing `UpdatedAt`. No owner key
947
+ * is involved, so this is the refresh / rotation path for any member once both its
948
+ * `CadrePeer` row (for the PublicKey) and its `DeviceToken` row exist. `PeerId` is
949
+ * intentionally not in the SET list (immutable; the constraint enforces
950
+ * `new.PeerId = old.PeerId`). Mirrors {@link updateSelfPeerRecord}.
951
+ */
952
+ async updateSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
953
+ this.ensureInitialized();
954
+ await this.execWrite(`
955
+ update CadreControl.DeviceToken
956
+ with context OwnerKey = null, Signature = ?
957
+ set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
958
+ where PeerId = ?
959
+ `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
960
+ log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
961
+ }
962
+
963
+ /**
964
+ * Insert the initial owner key (bootstrap - no existing owners required)
965
+ */
966
+ async insertOwnerKey(key: string): Promise<void> {
967
+ this.ensureInitialized();
968
+ log('Inserting owner key: %s', key);
969
+
970
+ // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
971
+ // committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
972
+ // signature is needed. Every other branch of `OwnerKey.Authorized` requires a signature
973
+ // from a PRE-EXISTING owner, so this method only ever succeeds on a fresh party; seating
974
+ // a second owner (or removing one) has no writer here and must sign the digests
975
+ // documented on the schema's `Authorized` constraint. We still persist a fresh, unique
976
+ // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
977
+ // the StampId is a real column value, not the optimystic `StampId()` SQL function.
978
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
979
+ await this.execWrite(`
980
+ insert into CadreControl.OwnerKey (Key, StampId)
981
+ with context OwnerKey = null, Signature = null
982
+ values (?, ?)
983
+ `, [key, stampId], 'owner-key-insert');
984
+ log('Owner key inserted');
985
+ }
986
+
987
+ /**
988
+ * Insert a strand into the control database using an owner signature.
989
+ *
990
+ * The owner signs the canonical row-bound authorization message (see
991
+ * {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
992
+ * this strand's contents and cannot be transplanted onto an attacker-chosen row. The
993
+ * StampId is persisted as a unique column for single-use anti-replay.
994
+ *
995
+ * @param strandId - Unique identifier for the strand
996
+ * @param type - Strand type: 'o' for open, 'c' for closed
997
+ * @param ownerKey - Public key of the authorizing owner
998
+ * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
999
+ * with the owner's private key, returning a base64url signature
1000
+ * @param memberPrivateKey - Optional private key for membership in closed strands
1001
+ */
1002
+ async insertStrand(
1003
+ strandId: string,
1004
+ type: 'o' | 'c',
1005
+ ownerKey: string,
1006
+ signMessage: (message: Uint8Array) => string,
1007
+ memberPrivateKey?: string
1008
+ ): Promise<void> {
1009
+ this.ensureInitialized();
1010
+ log('Inserting strand: %s (type: %s)', strandId, type);
1011
+
1012
+ // Generate a unique stamp ID using the peer ID for distributed uniqueness
1013
+ const peerId = this.config.libp2pNode.peerId.toString();
1014
+ const stampId = generateStampId(peerId);
1015
+
1016
+ // Field order MUST match the schema's Strand `AuthorizedInsert` verify:
1017
+ // Id, Type, MemberPrivateKey ('' when null), StampId.
1018
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
1019
+ const signature = signMessage(message);
1020
+
1021
+ // StampId is a real, unique column (single-use anti-replay), no longer a context value.
1022
+ await this.execWrite(`
1023
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
1024
+ with context OwnerKey = ?, Signature = ?
1025
+ values (?, ?, ?, ?)
1026
+ `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
1027
+
1028
+ log('Strand inserted: %s', strandId);
1029
+ }
1030
+
1031
+ /**
1032
+ * Delete a strand from the control database using an owner signature.
1033
+ *
1034
+ * Mirrors {@link insertStrand}'s row-bound approach for the delete half: the owner
1035
+ * signs the canonical `'remove'`-tagged authorization message over (Id, StampId) — the
1036
+ * schema's `Strand.AuthorizedDelete` verifies this DISTINCT digest, so the insert
1037
+ * approval (which never expires) can never be replayed as a removal. The delete and
1038
+ * the `Revocation` tombstone retiring the row's StampId commit in ONE transaction —
1039
+ * `Strand.RevocationRecorded` refuses a bare delete, and without the tombstone the
1040
+ * stamp would free up and the original formation approval could re-seat the strand.
1041
+ * Body shared with every other guarded delete via {@link deleteGuardedRow}.
1042
+ *
1043
+ * The remove digest binds only (Id, StampId) — not Type/MemberPrivateKey — so this
1044
+ * works identically for open and closed strands.
1045
+ *
1046
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
1047
+ * when a row was actually removed.
1048
+ */
1049
+ deleteStrand(
1050
+ strandId: string,
1051
+ ownerKey: string,
1052
+ signMessage: (message: Uint8Array) => string
1053
+ ): Promise<boolean> {
1054
+ return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage), {}, 'strand-delete');
1055
+ }
1056
+
1057
+ /**
1058
+ * Insert a validation key into the control database using an owner signature.
1059
+ *
1060
+ * Mirrors {@link insertStrand}: the owner signs the canonical row-bound
1061
+ * authorization message over (Key, StampId), and the StampId is persisted as a unique
1062
+ * column for single-use anti-replay. A `ValidationKey` authorizes verifying strand
1063
+ * formation disclosures.
1064
+ *
1065
+ * @param key - The validation public key to enroll
1066
+ * @param ownerKey - Public key of the authorizing owner
1067
+ * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
1068
+ * with the owner's private key, returning a base64url signature
1069
+ */
1070
+ async insertValidationKey(
1071
+ key: string,
1072
+ ownerKey: string,
1073
+ signMessage: (message: Uint8Array) => string
1074
+ ): Promise<void> {
1075
+ this.ensureInitialized();
1076
+ log('Inserting validation key: %s', key);
1077
+
1078
+ const peerId = this.config.libp2pNode.peerId.toString();
1079
+ const stampId = generateStampId(peerId);
1080
+
1081
+ // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
1082
+ const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
1083
+ const signature = signMessage(message);
1084
+
1085
+ await this.execWrite(`
1086
+ insert into CadreControl.ValidationKey (Key, StampId)
1087
+ with context OwnerKey = ?, Signature = ?
1088
+ values (?, ?)
1089
+ `, [ownerKey, signature, key, stampId], 'validation-key-insert');
1090
+
1091
+ log('Validation key inserted: %s', key);
1092
+ }
1093
+
1094
+ /**
1095
+ * Delete a validation key from the control database using an owner signature.
1096
+ *
1097
+ * Mirrors {@link insertValidationKey}'s row-bound approach for the delete half: the
1098
+ * owner signs the canonical `'remove'`-tagged authorization message over (Key,
1099
+ * StampId) — the schema's `ValidationKey.AuthorizedDelete` verifies this DISTINCT
1100
+ * digest, so the enrollment approval (which never expires) can never be replayed as a
1101
+ * removal. The delete and the `Revocation` tombstone retiring the row's StampId commit
1102
+ * in ONE transaction — `ValidationKey.RevocationRecorded` refuses a bare delete, and
1103
+ * without the tombstone the stamp would free up and the original enrollment approval
1104
+ * could re-seat the key. Body shared with every other guarded delete via
1105
+ * {@link deleteGuardedRow}.
1106
+ *
1107
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
1108
+ * when a row was actually removed.
1109
+ */
1110
+ deleteValidationKey(
1111
+ key: string,
1112
+ ownerKey: string,
1113
+ signMessage: (message: Uint8Array) => string
1114
+ ): Promise<boolean> {
1115
+ return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage), {}, 'validation-key-delete');
1116
+ }
1117
+
1118
+ /**
1119
+ * Owner-vouched INSERT of one `CadrePeer` membership row, notifying the membership
1120
+ * listener once it has committed.
1121
+ *
1122
+ * Mints a fresh single-use `StampId` and signs the `'vouch'`-tagged authorization
1123
+ * message over (PeerId, StampId) — see {@link buildAuthorizationMessage} — satisfying
1124
+ * the schema's `CadrePeer.AuthorizedInsert`. Binding the peer id to the nonce means a
1125
+ * captured insert approval cannot be replayed (live rows are blocked by the unique
1126
+ * `StampId` column, removed rows by `Revocation` retirement via `CadrePeer.NotRevoked`)
1127
+ * and cannot be repurposed as a delete (which signs a distinct `'remove'`-tagged digest).
1128
+ *
1129
+ * The owner signature does NOT cover the address columns — those are vouched only as far
1130
+ * as the owner asserts them, and a peer's own `Sig` (when present) is what makes the row
1131
+ * resolvable.
1132
+ *
1133
+ * The {@link mutateCadrePeer} wrapper lives here rather than in the caller so no
1134
+ * `CadrePeer` inserter can forget it — the write itself is what lets the admitted peer's
1135
+ * traffic in.
1136
+ *
1137
+ * Idempotent on an already-present row: two writers can legitimately race the SAME peer's
1138
+ * first row — the node's own background self-publish ({@link CadreNode.registerSelf})
1139
+ * against a foreground {@link SeedBootstrapService.authorizePeer} of that node's id — and
1140
+ * the write lock only serializes them; the loser would hit the `CadrePeer.PeerId` UNIQUE
1141
+ * constraint. The existence check runs INSIDE the locked body, so it sees the winner's
1142
+ * committed row (a pre-lock check would re-open the read-then-insert window). The existing
1143
+ * row — voucher, addresses, self-`Sig` — is left untouched; re-touching a live row is
1144
+ * {@link reauthorizeCadrePeer}'s job.
1145
+ *
1146
+ * @param row - the membership row's columns; `multiaddr` is the comma-joined address list.
1147
+ * @param ownerKey - public key of the vouching owner, persisted into `VouchOwner`.
1148
+ * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
1149
+ * private key, returning a base64url signature.
1150
+ * @returns `true` when this call performed the INSERT, `false` when the in-lock existence
1151
+ * check found the row already seated. The loser needs to know: an authorize seats a row
1152
+ * with a null `Sig`, so a self-publish that lost the race must fall through to a
1153
+ * self-update or its record never lands.
1154
+ */
1155
+ async insertCadrePeer(
1156
+ row: {
1157
+ peerId: string;
1158
+ publicKey: string | null;
1159
+ multiaddr: string;
1160
+ updatedAt: number;
1161
+ sig: string | null;
1162
+ },
1163
+ ownerKey: string,
1164
+ signMessage: (message: Uint8Array) => string
1165
+ ): Promise<boolean> {
1166
+ this.ensureInitialized();
1167
+ // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
1168
+ // row it vouches.
1169
+ const stampId = generateStampId(row.peerId);
1170
+ const signature = signMessage(
1171
+ buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId])
1172
+ );
1173
+ return await this.mutateCadrePeer('peer-insert', async () => {
1174
+ if (await this.queryStampId('CadrePeer', row.peerId) !== null) {
1175
+ log('CadrePeer row already present for %s; insert skipped (already a member)', row.peerId);
1176
+ return false;
1177
+ }
1178
+ // Bare `exec`: already inside the write lock, which is NOT re-entrant (see the NOTE
1179
+ // on withWriteLock — re-entry via execWrite hangs silently and permanently).
1180
+ //
1181
+ // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
1182
+ // identical to the context pair, which the AuthorizedInsert constraint binds — so a
1183
+ // reader can later re-check the voucher against its node-local trusted-owner anchor.
1184
+ await this.db!.exec(`
1185
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
1186
+ with context OwnerKey = ?, Signature = ?
1187
+ values (?, ?, ?, ?, ?, ?, ?, ?)
1188
+ `, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
1189
+ return true;
1190
+ });
1191
+ }
1192
+
1193
+ /**
1194
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
1195
+ * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
1196
+ * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
1197
+ * the row is re-emitted as a fresh, broadcasting transaction.
1198
+ *
1199
+ * This is the write-while-alone re-replication primitive: a membership row that committed
1200
+ * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
1201
+ * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
1202
+ * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
1203
+ * and voucher change — `PublicKey` / `Multiaddr` / `Sig` are left intact — so it is safe
1204
+ * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
1205
+ * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
1206
+ * `UpdatedAt` without re-signing would invalidate its self-signature).
1207
+ *
1208
+ * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
1209
+ * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
1210
+ * membership snapshot cannot have changed.
1211
+ *
1212
+ * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
1213
+ * that removes the row in between makes the `update` match nothing, and this still returns
1214
+ * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
1215
+ * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
1216
+ * re-add in the same window fails loudly instead, since the signature binds the retired
1217
+ * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
1218
+ * into the locked body.
1219
+ *
1220
+ * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
1221
+ * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
1222
+ * change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
1223
+ * beats a per-method exception the next reader has to relearn.
1224
+ *
1225
+ * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
1226
+ * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
1227
+ * reader's node-local anchor. Benign today because the only caller — the write-while-alone
1228
+ * drain — re-touches solely rows this node itself authored (`pendingPeerWrites`), so the
1229
+ * voucher is rewritten to the key that already signed it. If a future path ever lets one
1230
+ * owner re-touch a row a DIFFERENT owner vouched, the voucher silently flips: readers that
1231
+ * anchor the original owner but not this one would drop a legitimate member. Such a path
1232
+ * must re-vouch deliberately (or preserve the existing VouchOwner/VouchSig) rather than
1233
+ * inherit this rebinding.
1234
+ *
1235
+ * @param peerId - the membership row to re-touch.
1236
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
1237
+ * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
1238
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
1239
+ * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
1240
+ */
1241
+ async reauthorizeCadrePeer(
1242
+ peerId: string,
1243
+ updatedAt: number,
1244
+ ownerKey: string,
1245
+ signMessage: (message: Uint8Array) => string
1246
+ ): Promise<boolean> {
1247
+ this.ensureInitialized();
1248
+ const stampId = await this.queryStampId('CadrePeer', peerId);
1249
+ if (stampId === null) {
1250
+ log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
1251
+ return false;
1252
+ }
1253
+ const signature = signMessage(
1254
+ buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId])
1255
+ );
1256
+ await this.mutateCadrePeer('peer-reauthorize', async () => {
1257
+ // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1258
+ await this.db!.exec(`
1259
+ update CadreControl.CadrePeer
1260
+ with context OwnerKey = ?, Signature = ?
1261
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1262
+ where PeerId = ?
1263
+ `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
1264
+ });
1265
+ return true;
1266
+ }
1267
+
1268
+ /**
1269
+ * Owner-signed removal of one `CadrePeer` membership row, notifying the membership
1270
+ * listener once it has committed.
1271
+ *
1272
+ * Mirrors {@link deleteStrand} for the membership table: the owner signs the DISTINCT
1273
+ * `'remove'`-tagged digest over (PeerId, StampId), so the row's stored `VouchSig` can
1274
+ * never be replayed to delete it, and the `Revocation` tombstone retiring the stamp
1275
+ * lands in the SAME transaction — `CadrePeer.RevocationRecorded` refuses a bare delete,
1276
+ * and without the tombstone the never-expiring admission approval (which the removed
1277
+ * peer holds a copy of) could re-seat the row.
1278
+ *
1279
+ * The {@link mutateCadrePeer} wrapper lives here rather than in the caller so no
1280
+ * `CadrePeer` remover can forget it. It notifies whenever the body resolves, including
1281
+ * the absent-row no-op below — a caller that must not notify for an already-absent peer
1282
+ * gates on {@link queryCadrePeerStampId} first (see
1283
+ * {@link SeedBootstrapService.removePeer}).
1284
+ *
1285
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
1286
+ * when a row was actually removed.
1287
+ */
1288
+ deleteCadrePeer(
1289
+ peerId: string,
1290
+ ownerKey: string,
1291
+ signMessage: (message: Uint8Array) => string
1292
+ ): Promise<boolean> {
1293
+ return this.mutateCadrePeer('peer-remove', () =>
1294
+ this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
1295
+ }
1296
+
1297
+ /**
1298
+ * Owner-signed removal of one peer's `DeviceToken` row (logout / token invalidation).
1299
+ *
1300
+ * Mirrors {@link deleteStrand}: the owner signs the DISTINCT `'remove'`-tagged digest
1301
+ * over (PeerId, StampId), so the insert approval can never be replayed to clear a token,
1302
+ * and the `Revocation` tombstone retiring the stamp lands in the SAME transaction —
1303
+ * `DeviceToken.RevocationRecorded` refuses a bare delete, and without the tombstone the
1304
+ * never-expiring insert approval (which the cleared device holds a copy of) could
1305
+ * re-seat the token.
1306
+ *
1307
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
1308
+ * when a row was actually removed.
1309
+ */
1310
+ deleteDeviceToken(
1311
+ peerId: string,
1312
+ ownerKey: string,
1313
+ signMessage: (message: Uint8Array) => string
1314
+ ): Promise<boolean> {
1315
+ return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage), {}, 'device-token-delete');
1316
+ }
1317
+
1318
+ /**
1319
+ * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
1320
+ * stamp, in ONE transaction. The single body behind EVERY guarded delete —
1321
+ * {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
1322
+ * {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
1323
+ * of those is a thin named wrapper, so callers never pass a table name and this
1324
+ * generic shape stays off the public surface.
1325
+ *
1326
+ * `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
1327
+ * production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
1328
+ * requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
1329
+ * it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
1330
+ * needs no change.
1331
+ *
1332
+ * The row's CURRENT stamp is read first and signed over, so the remove digest binds to
1333
+ * this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
1334
+ * reported as `false` so a caller can tell "removed" from "was never there" without a
1335
+ * second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
1336
+ * which must not fire for a no-op).
1337
+ *
1338
+ * NOTE: the stamp read is outside the transaction. A concurrent writer that removes
1339
+ * the row in between makes the signature bind a stamp that is no longer live; the
1340
+ * delete then matches nothing and the tombstone insert collides with the other
1341
+ * writer's on `Revocation`'s (TableName, StampId) primary key, so the transaction
1342
+ * fails rather than silently half-applying. If concurrent owner-device removals ever
1343
+ * become routine, fold the stamp read into the transaction instead.
1344
+ *
1345
+ * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
1346
+ * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
1347
+ *
1348
+ * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
1349
+ * come from closed literal unions — no caller-supplied string reaches the statement.
1350
+ */
1351
+ private async deleteGuardedRow(
1352
+ table: Exclude<RevocableTable, 'OwnerKey'>,
1353
+ keyValue: string,
1354
+ ownerKey: string,
1355
+ signMessage: (message: Uint8Array) => string
1356
+ ): Promise<boolean> {
1357
+ this.ensureInitialized();
1358
+ const stampId = await this.queryStampId(table, keyValue);
1359
+ if (stampId === null) {
1360
+ log('delete %s: no row for %s (already absent)', table, keyValue);
1361
+ return false;
1362
+ }
1363
+
1364
+ const message = buildAuthorizationMessage(`CadreControl.${table}`, 'remove', [keyValue, stampId]);
1365
+ const signature = signMessage(message);
1366
+ // The tombstone carries its OWN owner signature (`Revocation.Authorized`): retiring a
1367
+ // stamp is permanent and party-wide, so the delete's signature deliberately does not
1368
+ // cover it — the digests are domain-separated and neither replays as the other.
1369
+ const revocationSignature = signMessage(
1370
+ buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]),
1371
+ );
1372
+
1373
+ await this.inTransaction(`delete ${table}`, async () => {
1374
+ await this.db!.exec(`
1375
+ delete from CadreControl.${table}
1376
+ with context OwnerKey = ?, Signature = ?
1377
+ where ${GUARDED_KEY_COLUMN[table]} = ?
1378
+ `, [ownerKey, signature, keyValue]);
1379
+ // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1380
+ // than leaning on the column default — the seat-at-zero rule is load-bearing for
1381
+ // reissueRevocations' monotonic bump, so state it at the write site.
1382
+ await this.db!.exec(`
1383
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1384
+ with context OwnerKey = ?, Signature = ?
1385
+ values (?, ?, ?, 0)
1386
+ `, [ownerKey, revocationSignature, table, keyValue, stampId]);
1387
+ });
1388
+
1389
+ log('%s deleted: %s (stamp retired)', table, keyValue);
1390
+ if (this.guardedDeleteListener) {
1391
+ try {
1392
+ this.guardedDeleteListener({ tableName: table, rowKey: keyValue, stampId });
1393
+ } catch (error) {
1394
+ log('guarded-delete listener threw (committed delete unaffected): %o', error);
1395
+ }
1396
+ }
1397
+ return true;
1398
+ }
1399
+
1400
+ /**
1401
+ * Delete one guarded row that an ALREADY-COMMITTED `Revocation` tombstone retires —
1402
+ * the local catch-up for a node that converged on a removal's tombstone while still
1403
+ * holding the removed row. That live-row-plus-tombstone state is reachable only by
1404
+ * replication merge (local writes cannot build it: `Revocation.RowIsGone` refuses a
1405
+ * tombstone while the row is live), and replication cannot carry the delete itself —
1406
+ * replaying a delete of a row that is already gone locally is a no-op — so without
1407
+ * this the stale row is permanent garbage on every node that held it at revocation
1408
+ * time. Authorized by the REAP branch of the table's `AuthorizedDelete`: the
1409
+ * tombstone is itself owner-signed (`Revocation.Authorized`), so no owner key is
1410
+ * needed HERE — any node holding row + tombstone may reap, drones included.
1411
+ *
1412
+ * Returns whether a row was removed: `false` without writing when the row is absent
1413
+ * locally (the common case on most nodes), or when its live stamp is not `stampId`
1414
+ * (a fresh incarnation the owner re-seated, which the old tombstone must not touch).
1415
+ *
1416
+ * The delete's WHERE clause binds `StampId` too — required, not belt-and-braces: an
1417
+ * owner may re-seat the row (fresh stamp) between the stamp read below and the
1418
+ * statement, and without the clause the reap would delete the owner's brand-new row.
1419
+ * With it the statement matches nothing and the reap is a silent no-op (the schema
1420
+ * branch would also refuse that delete, but as a constraint error thrown into a
1421
+ * background sweep — matched-nothing is the better failure mode). Like
1422
+ * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
1423
+ * harmless — nothing was deleted, and the next sweep re-reads.
1424
+ *
1425
+ * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
1426
+ * committed tombstone that authorizes the reap, so a reap files no second tombstone.
1427
+ * It therefore does NOT fire the guarded-delete listener either — that seam exists to
1428
+ * queue a tombstone for re-issue when it committed while alone, and a reap has no
1429
+ * tombstone of its own to re-issue. A reap that commits while alone needs no
1430
+ * re-replication at all: every OTHER node either already lacks the row, or holds the
1431
+ * same committed tombstone and reaps its own copy.
1432
+ *
1433
+ * NOTE: the reap delete is keyed on the primary key; the `StampId` predicate is applied
1434
+ * where the statement runs. If a reap on one node ever has to be reconciled against an
1435
+ * owner re-seat of the same key that landed on another node, whether the delete or the
1436
+ * re-seat wins is decided by the collection's merge order, not by this clause — the same
1437
+ * ordering question the owner-signed delete path already carries
1438
+ * (`tickets/blocked/forked-control-collection-sync-livelocks.md`). Only matters once a
1439
+ * sweep drives this automatically on connected nodes; if that reconcile ever drops a
1440
+ * freshly re-seated row, this is the site.
1441
+ *
1442
+ * `table` and its {@link GUARDED_KEY_COLUMN} column come from closed literal unions —
1443
+ * no caller-supplied string reaches the statement (same injection-surface discipline
1444
+ * as {@link deleteGuardedRow}). The `with context` clause is PRESENT and bound to
1445
+ * nulls, never omitted: Quereus cannot resolve `context.OwnerKey` at plan time when
1446
+ * the clause is absent while a constraint references `context.*` (nulls through a
1447
+ * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
1448
+ *
1449
+ * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
1450
+ * reconcile pass runs while the node has at least one control connection.
1451
+ */
1452
+ async reapRevokedRow(table: ReapableTable, rowKey: string, stampId: string): Promise<boolean> {
1453
+ this.ensureInitialized();
1454
+ const liveStamp = await this.queryStampId(table, rowKey);
1455
+ if (liveStamp === null) {
1456
+ log('reap %s: no row for %s (nothing to reap)', table, rowKey);
1457
+ return false;
1458
+ }
1459
+ if (liveStamp !== stampId) {
1460
+ log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
1461
+ return false;
1462
+ }
1463
+ const sql = `
1464
+ delete from CadreControl.${table}
1465
+ with context OwnerKey = null, Signature = null
1466
+ where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1467
+ `;
1468
+ if (table === 'CadrePeer') {
1469
+ // Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
1470
+ // invariant keeps holding with no new documented exception. The membership
1471
+ // snapshot cannot actually change — queryCadrePeers already dropped this row via
1472
+ // the retired-stamp gate — so the notify is a redundant refresh. Bare exec:
1473
+ // already inside the non-re-entrant write lock (see insertCadrePeer).
1474
+ //
1475
+ // NOTE: reapRevokedRows calls this in a loop, so a sweep that clears K CadrePeer
1476
+ // rows fires K redundant gate refreshes (two table scans each), awaited serially.
1477
+ // Harmless today — K is the backlog of unreaped rows, which a party burns down to
1478
+ // 0 on its first connected tick and holds there. If a party ever reaps many rows
1479
+ // in one pass, hoist the notify to one refresh after the sweep instead.
1480
+ await this.mutateCadrePeer('peer-reap', () => this.db!.exec(sql, [rowKey, stampId]));
1481
+ } else {
1482
+ await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
1483
+ }
1484
+ // A row leaving the control plane without an owner signature at the write site
1485
+ // should be visible in a log.
1486
+ log('REAPED %s row %s (stamp %s): committed tombstone authorized local removal, no owner signature', table, rowKey, stampId);
1487
+ return true;
1488
+ }
1489
+
1490
+ /**
1491
+ * Sweep every locally-held `Revocation` tombstone and {@link reapRevokedRow} the guarded
1492
+ * row each one retires, returning how many rows were actually removed.
1493
+ *
1494
+ * This is the enumeration half of the reap: replication carries the tombstone but cannot
1495
+ * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
1496
+ * that converged on a removal while holding the removed row keeps that row as inert
1497
+ * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
1498
+ * control-cohort reconcile pass is that something, gated on the node having at least one
1499
+ * control connection (a reap is a write, and a write committed alone is local-only).
1500
+ *
1501
+ * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
1502
+ * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
1503
+ * re-entrant, so this loop must not hold it. Same structure as
1504
+ * `CadreNode.reissueAuthoredMembershipRows`.
1505
+ *
1506
+ * Two rows are deliberately left alone:
1507
+ *
1508
+ * - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `OwnerKey`) — no reap branch
1509
+ * exists on their `AuthorizedDelete`, so the delete would throw rather than no-op.
1510
+ * - **This node's OWN `CadrePeer` / `DeviceToken` row** (`selfPeerId`). Reaping it would
1511
+ * fight this node's own re-registration: `registerSelf` / `retouchSelfDeviceToken` run
1512
+ * on the heartbeat and the growth drain, and after a self-reap their insert-if-absent
1513
+ * guard takes the INSERT path — which needs an owner signature `NotRevoked` refuses for
1514
+ * a retired stamp, and which a revoked drone cannot produce at all. The trade is
1515
+ * recurring failure noise every cycle against removing the one stale row nobody else
1516
+ * reads from this node (every other node already filters it by tombstone). A revoked
1517
+ * node therefore keeps its own copy of its own row; "a node that learns it has been
1518
+ * revoked should shut itself down" is a distinct behaviour and its own ticket.
1519
+ * `ValidationKey` has no self notion and is not special-cased.
1520
+ *
1521
+ * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
1522
+ * starve every tombstone after the failing one on every subsequent pass, and each row is
1523
+ * independent. Returns the count so far if the database is closed mid-sweep.
1524
+ *
1525
+ * `selfPeerId` is a parameter rather than read from the injected libp2p node so the
1526
+ * skip-self rule is exercisable without one.
1527
+ *
1528
+ * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
1529
+ * one {@link queryStampId} point lookup per tombstone, and the empty-table early return
1530
+ * makes the common case (a party that has never revoked anyone) a single scan per pass.
1531
+ * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
1532
+ * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
1533
+ * party; if the table ever grows, persist a node-local high-water mark of what has
1534
+ * already been reaped instead of re-scanning everything (the same bound the sweep in
1535
+ * `CadreNode.drainPendingRevocations` wants).
1536
+ */
1537
+ async reapRevokedRows(selfPeerId: string): Promise<number> {
1538
+ this.ensureInitialized();
1539
+ const held = await this.queryRevocations();
1540
+ if (held.length === 0) {
1541
+ return 0;
1542
+ }
1543
+ let reaped = 0;
1544
+ for (const tombstone of held) {
1545
+ // Teardown guard, per row: close() can land mid-sweep (the reconcile pass that
1546
+ // drives this outlives no node, but it does not hold the node's lifecycle either).
1547
+ if (!this.initialized || !this.db) {
1548
+ return reaped;
1549
+ }
1550
+ if (!isReapableTable(tombstone.tableName)) {
1551
+ continue;
1552
+ }
1553
+ if (tombstone.rowKey === selfPeerId
1554
+ && (tombstone.tableName === 'CadrePeer' || tombstone.tableName === 'DeviceToken')) {
1555
+ continue;
1556
+ }
1557
+ try {
1558
+ if (await this.reapRevokedRow(tombstone.tableName, tombstone.rowKey, tombstone.stampId)) {
1559
+ reaped++;
1560
+ }
1561
+ } catch (error) {
1562
+ log('reap sweep: %s row %s (stamp %s) failed (continuing): %o',
1563
+ tombstone.tableName, tombstone.rowKey, tombstone.stampId, error);
1564
+ }
1565
+ }
1566
+ return reaped;
1567
+ }
1568
+
1569
+ /**
1570
+ * Owner-signed re-issue of a batch of `Revocation` tombstones: bump each row's
1571
+ * `ReissuedAt` to `reissuedAt` in ONE transaction. Re-writing the row is what makes
1572
+ * the storage layer re-broadcast it — a tombstone that committed while the node was
1573
+ * alone is local-only, and unlike an insert a delete cannot be replayed (the guarded
1574
+ * row is already gone locally), so the tombstone is the half of a removal that can
1575
+ * still carry it to the rest of the party.
1576
+ *
1577
+ * One transaction, not one per row: a sweep may cover every tombstone in the table,
1578
+ * and each separate commit is a separate round of network work. One owner signature
1579
+ * per row: `AuthorizedReissue`'s digest binds (TableName, RowKey, StampId,
1580
+ * ReissuedAt). `reissuedAt` must be STRICTLY above every affected row's current
1581
+ * value (`ReissueOnly`); callers derive it as `Math.max(Date.now(), max(existing) + 1)`,
1582
+ * mirroring {@link CadreNode.reissuePeerAuthorize}'s monotonic bump. Re-issuing a
1583
+ * tombstone whose guarded row was never present locally is the normal case (a node
1584
+ * that converged on the tombstone but never held the row) — a re-issue files no
1585
+ * delete, so `RowIsGone` / `RevocationRecorded` are not involved.
1586
+ *
1587
+ * A constraint failure on any row rolls the WHOLE batch back and propagates —
1588
+ * {@link lockedWithRetry}'s classifier only re-presents transient cluster failures,
1589
+ * so a real refusal (non-owner signer, or a stale counter when two owner devices
1590
+ * sweep concurrently and the loser's `ReissueOnly` CHECK fails) surfaces to the
1591
+ * caller, which retries with a fresh counter on its next sweep. A row whose
1592
+ * `StampId` matches nothing locally is a silent no-op statement; callers enumerate
1593
+ * via {@link queryRevocations} first, so they only name stamps they hold.
1594
+ *
1595
+ * Returns how many UPDATE statements ran (`rows.length` on success).
1596
+ */
1597
+ reissueRevocations(
1598
+ rows: readonly RevocationRow[],
1599
+ reissuedAt: number,
1600
+ ownerKey: string,
1601
+ signMessage: (message: Uint8Array) => string
1602
+ ): Promise<number> {
1603
+ this.ensureInitialized();
1604
+ // Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
1605
+ // exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
1606
+ // rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
1607
+ // and every digest field is TEXT on both sides (see buildAuthorizationMessage).
1608
+ const signed = rows.map((row) => ({
1609
+ row,
1610
+ signature: signMessage(buildAuthorizationMessage(
1611
+ 'CadreControl.Revocation', 'reissue',
1612
+ [row.tableName, row.rowKey, row.stampId, String(reissuedAt)]
1613
+ )),
1614
+ }));
1615
+ return this.lockedWithRetry(async () => {
1616
+ // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
1617
+ // re-executes the whole body) starts from zero instead of double-counting.
1618
+ let executed = 0;
1619
+ await this.inTransaction('reissue revocations', async () => {
1620
+ for (const { row, signature } of signed) {
1621
+ // Keyed on StampId ALONE, deliberately. Equality on every column of the
1622
+ // composite primary key (TableName, StampId) makes the optimystic vtab claim
1623
+ // the predicate as a point lookup with no engine-side re-check, and that
1624
+ // descent has been observed returning zero rows for a row that provably
1625
+ // exists (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
1626
+ // A re-issue that silently matched nothing would report success and
1627
+ // replicate nothing — the exact failure this path exists to remove. StampId
1628
+ // alone is a non-leading subset of the key, which the module declines, so
1629
+ // the read is a scan the engine filters; a StampId is 128 bits of CSPRNG
1630
+ // output minted per row incarnation (generateStampId), so it identifies one
1631
+ // row across all TableName values. Do NOT "fix" this by adding TableName to
1632
+ // the where clause.
1633
+ await this.db!.exec(`
1634
+ update CadreControl.Revocation
1635
+ with context OwnerKey = ?, Signature = ?
1636
+ set ReissuedAt = ?
1637
+ where StampId = ?
1638
+ `, [ownerKey, signature, reissuedAt, row.stampId]);
1639
+ executed++;
1640
+ }
1641
+ });
1642
+ log('Reissued %d revocation tombstone(s) at %d', executed, reissuedAt);
1643
+ return executed;
1644
+ }, {}, 'revocation-reissue');
1645
+ }
1646
+
1647
+ /**
1648
+ * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
1649
+ *
1650
+ * A failed `commit()` has already torn the transaction down, so the `rollback()` in
1651
+ * the failure path would itself throw "No transaction active" and mask the real
1652
+ * cause — that secondary throw is logged and swallowed, and the original error always
1653
+ * propagates.
1654
+ *
1655
+ * It opens a transaction unconditionally (Quereus hard-throws on a nested
1656
+ * `beginTransaction`), so a caller already inside one must not call it. A `CadrePeer`
1657
+ * write nests this INSIDE {@link mutateCadrePeer}, never the reverse — see
1658
+ * {@link assertCommitBoundary}.
1659
+ *
1660
+ * Private: every multi-statement control write lives in this class (the owner-signed
1661
+ * delete/tombstone pairs all go through {@link deleteGuardedRow}), so needing to widen
1662
+ * this is a sign the writer that wants it belongs in here too.
1663
+ *
1664
+ * Deliberately NOT wrapped in {@link withWriteLock}: callers already hold the
1665
+ * (non-re-entrant) lock, so taking it again here would self-deadlock.
1666
+ *
1667
+ * @param label - What the transaction was doing, for the rollback log line.
1668
+ */
1669
+ private async inTransaction(label: string, body: () => Promise<void>): Promise<void> {
1670
+ this.ensureInitialized();
1671
+ await this.db!.beginTransaction();
1672
+ try {
1673
+ await body();
1674
+ await this.db!.commit();
1675
+ } catch (error) {
1676
+ try {
1677
+ await this.db!.rollback();
1678
+ } catch (rollbackError) {
1679
+ log('Rollback after %s failure was a no-op: %s', label, rollbackError);
1680
+ }
1681
+ throw error;
1682
+ }
1683
+ }
1684
+
1685
+ /**
1686
+ * Wire (or clear, with null) the single listener notified after every committed
1687
+ * `CadreControl.CadrePeer` row write.
1688
+ *
1689
+ * At most one listener: a ControlDatabase instance belongs to exactly one
1690
+ * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
1691
+ * second call replaces the first rather than fanning out, so a stale node can
1692
+ * never keep receiving notifications for a database it no longer owns.
1693
+ */
1694
+ setMembershipChangeListener(listener: MembershipChangeListener | null): void {
1695
+ this.membershipListener = listener;
1696
+ }
1697
+
1698
+ /**
1699
+ * Wire (or clear, with null) the single listener notified after every committed
1700
+ * guarded-table delete. Same ownership contract as
1701
+ * {@link setMembershipChangeListener}: one CadreNode, one listener, a second
1702
+ * call replaces the first.
1703
+ */
1704
+ setGuardedDeleteListener(listener: GuardedDeleteListener | null): void {
1705
+ this.guardedDeleteListener = listener;
1706
+ }
1707
+
1708
+ /**
1709
+ * Run a `CadrePeer` row mutation and notify the membership listener once it has
1710
+ * COMMITTED.
1711
+ *
1712
+ * EVERY `CadrePeer` writer goes through here, with one documented exception
1713
+ * ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
1714
+ * the party-membership snapshot refresh automatic rather than a caller obligation. A
1715
+ * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
1716
+ * event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
1717
+ * `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
1718
+ * callbacks wired), so this is the one point on the write path that cannot be bypassed.
1719
+ *
1720
+ * `body` owns its own transaction if it needs one and must COMMIT before returning, so
1721
+ * the notification never makes the listener read uncommitted state. A throwing `body`
1722
+ * propagates and does NOT notify: nothing changed.
1723
+ *
1724
+ * That contract is enforced, not merely documented — see {@link assertCommitBoundary}.
1725
+ *
1726
+ * Runs under {@link withWriteLock}, notification included: with the lock held through
1727
+ * the notify, no other local writer can have a transaction open while the listener
1728
+ * reads, so the listener always sees exactly the committed state it was told about.
1729
+ * The listener itself only READS (it re-materializes the membership snapshot) — a
1730
+ * listener that wrote through a locked method would deadlock.
1731
+ *
1732
+ * Reaches the lock through {@link lockedWithRetry}, so after a transient cluster
1733
+ * failure the WHOLE locked body re-runs — `body` must be atomic and re-runnable (the
1734
+ * contract on {@link withWriteLock}). The notify sits after a successful `body` inside
1735
+ * the same attempt and a throwing attempt never reaches it, so a retried mutation
1736
+ * still notifies exactly once: on the attempt that commits, and not at all on
1737
+ * exhaustion.
1738
+ *
1739
+ * @param reason - Label for the log line only (e.g. `'peer-insert'`).
1740
+ */
1741
+ async mutateCadrePeer<T>(reason: string, body: () => Promise<T>): Promise<T> {
1742
+ this.ensureInitialized();
1743
+ return this.lockedWithRetry(async () => {
1744
+ this.assertCommitBoundary(reason, 'on entry to');
1745
+ const result = await body();
1746
+ this.assertCommitBoundary(reason, 'on return from');
1747
+ await this.notifyMembershipChanged(reason);
1748
+ return result;
1749
+ }, {}, reason);
1750
+ }
1751
+
1752
+ /**
1753
+ * Run one local write under the database-wide write lock, serializing it against
1754
+ * every other local writer on this ControlDatabase.
1755
+ *
1756
+ * Quereus tracks transaction state per `Database` (`getAutocommit()`), and a write
1757
+ * statement's implicit transaction stays open across the awaits inside `exec`. Two
1758
+ * local writers interleaving mid-statement therefore either trip
1759
+ * {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
1760
+ * open transaction — a torn commit if either side rolls back. This lock closes that
1761
+ * class: every public write method of this class runs its statement(s) under it, and
1762
+ * `SeedBootstrapService` wraps its direct `db.exec` writes in it too. The race is
1763
+ * real, not theoretical: the background self-record publish on a control connection
1764
+ * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
1765
+ * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
1766
+ * assert.
1767
+ *
1768
+ * Reads are deliberately not locked: they take no transaction of their own, and
1769
+ * serializing them here would let a listener that reads during a notify (see
1770
+ * {@link mutateCadrePeer}) deadlock. The lock is NOT re-entrant — a locked writer's
1771
+ * body must never call another locked public method; the private bodies it composes
1772
+ * ({@link deleteGuardedRow}, {@link inTransaction}) stay bare for exactly that reason.
1773
+ *
1774
+ * NOTE: re-entry fails SILENTLY and PERMANENTLY. A locked body that calls another
1775
+ * locked public method (including a {@link mutateCadrePeer} body, which runs an
1776
+ * arbitrary caller-supplied callback) queues behind its own tail and never resolves —
1777
+ * no error, and it strands the whole write queue, not only that call. There is no
1778
+ * cheap fail-fast: a "held" flag cannot tell re-entry from a legitimately queued
1779
+ * concurrent writer. Compose bare private bodies instead.
1780
+ *
1781
+ * A locked body must also be ATOMIC and RE-RUNNABLE: the public write surface reaches
1782
+ * this lock through {@link lockedWithRetry}, which re-runs the WHOLE body after a
1783
+ * transient cluster failure. Atomic holds for every body today — one statement, or
1784
+ * wrapped in {@link inTransaction} — so a failed attempt leaves nothing half-applied;
1785
+ * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
1786
+ * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
1787
+ * attempt: a retry re-presents the exact signed message the first attempt presented.
1788
+ */
1789
+ async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
1790
+ // Chain behind the current tail regardless of how it settled, then park a
1791
+ // swallowed copy as the new tail so one failed write never poisons the queue.
1792
+ const run = this.writeQueue.then(fn, fn);
1793
+ this.writeQueue = run.then(() => undefined, () => undefined);
1794
+ return run;
1795
+ }
1796
+
1797
+ /**
1798
+ * {@link withWriteLock} plus the bounded transient-failure retry
1799
+ * ({@link retryControlWrite}) — the wrapper the whole public write surface goes
1800
+ * through, so a control write that failed because the cluster cohort did not answer is
1801
+ * re-presented a moment later instead of surfacing to ~19 callers that are not
1802
+ * uniformly written to retry.
1803
+ *
1804
+ * The retry wraps the LOCK, it does not live inside it: each attempt takes and
1805
+ * releases the lock, and the backoff sleeps with NO lock held, so a write parked in
1806
+ * backoff never stalls the other local writers queued behind it. Safe because every
1807
+ * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
1808
+ *
1809
+ * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
1810
+ * `initialized` is set — so it reaches this method directly rather than through
1811
+ * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
1812
+ * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
1813
+ * rather than carving out a lockless path. It is also the ONLY caller that passes a
1814
+ * `policy` — everything else takes the default, whose classifier and attempt count are
1815
+ * unchanged by that call site existing.
1816
+ *
1817
+ * `label` names the operation in this loop's debug lines and nothing else
1818
+ * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
1819
+ * several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
1820
+ * attributed to a write — keep new call sites labelled.
1821
+ */
1822
+ private lockedWithRetry<T>(fn: () => Promise<T>, policy: ControlWriteRetryOptions = {}, label?: string): Promise<T> {
1823
+ // The label lands LAST so a call site's own name survives both the policy and the
1824
+ // spec-injected pacing; it changes log attribution only, never behaviour.
1825
+ return retryControlWrite(
1826
+ () => this.withWriteLock(fn),
1827
+ { ...policy, ...this.controlWriteRetryPacing, ...(label !== undefined ? { label } : {}) }
1828
+ );
1829
+ }
1830
+
1831
+ /**
1832
+ * One-statement local write, serialized by {@link withWriteLock} and retried on a
1833
+ * transient cluster failure via {@link lockedWithRetry}. Prefer this over a bare
1834
+ * `getDatabase().exec` so a new writer cannot forget the lock — the hazard
1835
+ * {@link assertCommitBoundary} can only catch after the fact.
1836
+ *
1837
+ * Public so `SeedBootstrapService`'s direct `CadrePeer`/`DeviceToken` SQL writes go
1838
+ * through the same seam. Must NOT be called from inside an already-locked body (the
1839
+ * lock is not re-entrant); such a caller uses a bare `getDatabase().exec` instead.
1840
+ *
1841
+ * `label` names the write in the retry loop's debug lines (see {@link lockedWithRetry});
1842
+ * optional only because it is log-only, but every caller in this repo passes one.
1843
+ */
1844
+ execWrite(sql: string, params?: SqlParameters, label?: string): Promise<void> {
1845
+ this.ensureInitialized();
1846
+ return this.lockedWithRetry(() => this.db!.exec(sql, params), {}, label);
1847
+ }
1848
+
1849
+ /**
1850
+ * Throw unless the database sits at a committed boundary — `getAutocommit()` is false
1851
+ * exactly while a transaction is open.
1852
+ *
1853
+ * Both ends of a {@link mutateCadrePeer} body are checked, because either open
1854
+ * transaction would notify a listener that then reads PRE-commit state and materializes
1855
+ * a membership snapshot silently missing the write it was told about:
1856
+ *
1857
+ * - open on entry: the wrapper sits INSIDE an enclosing transaction and must be moved
1858
+ * out to enclose that outer commit.
1859
+ * - open on return: the body opened a transaction and never committed it.
1860
+ *
1861
+ * Neither is reachable from today's callers, so this is a fail-fast guard on a future
1862
+ * mistake rather than a live code path.
1863
+ *
1864
+ * NOTE: `getAutocommit()` reports the whole `Database`, not this call. Local writers
1865
+ * serialize through {@link withWriteLock}, so with every writer locked this can only
1866
+ * trip on the two misuses above. A writer that bypasses the lock (a new method that
1867
+ * forgets it, or raw `getDatabase().exec` outside a locked wrapper) re-opens the
1868
+ * concurrent-writer window, and this assert is what catches it — it throws instead of
1869
+ * silently joining the other write's transaction.
1870
+ */
1871
+ private assertCommitBoundary(reason: string, where: string): void {
1872
+ if (this.db!.getAutocommit()) {
1873
+ return;
1874
+ }
1875
+ throw new Error(
1876
+ `mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
1877
+ 'CadrePeer write must commit before the membership listener runs; move the ' +
1878
+ 'mutateCadrePeer wrapper out to enclose the commit'
1879
+ );
1880
+ }
1881
+
1882
+ /**
1883
+ * Best-effort notify. A listener that throws is logged and swallowed: the write has
1884
+ * already committed, and it must never be reported as failed because a downstream
1885
+ * snapshot refresh did. A null listener (no `CadreNode` attached — e.g. the service
1886
+ * unit tests drive a bare control DB) is a silent no-op.
1887
+ */
1888
+ private async notifyMembershipChanged(reason: string): Promise<void> {
1889
+ if (!this.membershipListener) {
1890
+ return;
1891
+ }
1892
+ try {
1893
+ await this.membershipListener(reason);
1894
+ } catch (error) {
1895
+ log('Membership change listener failed (%s): %s', reason, error);
1896
+ }
1897
+ }
1898
+
1899
+ /**
1900
+ * Insert an owner-signed `FormationInvite` (open invitation token).
1901
+ *
1902
+ * The invite is the on-network record that later authorizes an
1903
+ * owner-signature-FREE `Strand` creation: an invited cadre peer redeems it
1904
+ * by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
1905
+ * which satisfies the consent branch of `Strand.AuthorizedInsert`.
1906
+ *
1907
+ * Like {@link insertStrand}/{@link insertValidationKey}, the owner signs the
1908
+ * canonical row-bound authorization message (see {@link buildAuthorizationMessage})
1909
+ * over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
1910
+ * bare stamp — so the signature is bound to this invite's contents and cannot be
1911
+ * transplanted onto an attacker-chosen row. The StampId is persisted as a unique
1912
+ * column for single-use anti-replay. `FormationInvite.AuthorizedInsert` gates insert
1913
+ * over an `'add'`-tagged digest; deletes verify a DISTINCT `'remove'`-tagged digest
1914
+ * (`AuthorizedDelete`), so this insert approval can never be replayed as a revocation.
1915
+ *
1916
+ * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
1917
+ * when set, a responder redeeming this token records a `FormationUsage` against that
1918
+ * strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
1919
+ * null `StrandId` (the default) leaves the legacy responder-provisions path in place.
1920
+ * Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
1921
+ *
1922
+ * The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
1923
+ * because it has a subquery) CHECK sees AFTER column coercion: TotalUses becomes a
1924
+ * decimal string (`String(totalUses)` ⇔ `cast(new.TotalUses as text)`) and ExpiresAt
1925
+ * becomes the engine's canonical `PlainDateTime` string — sourced here from
1926
+ * {@link canonicalDatetime} (a `select datetime(?)` round-trip) rather than a hand-rolled
1927
+ * ISO formatter, so signer and verifier agree exactly. A null ExpiresAt / TotalUses /
1928
+ * ValidationUrl signs as `''`, matching the schema's `coalesce(..., '')`.
1929
+ *
1930
+ * @param token - Invitation token (the `FormationInvite` primary key)
1931
+ * @param sAppId - The sApp a redeemed strand will use
1932
+ * @param ownerKey - Public key of the authorizing owner
1933
+ * @param signMessage - ed25519-signs the raw message bytes (no pre-hash),
1934
+ * returning a base64url signature — the same callback shape {@link insertStrand} uses
1935
+ * @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
1936
+ * `strandId` (bind to a pre-existing host strand for provision-then-record)
1937
+ */
1938
+ async insertFormationInvite(
1939
+ token: string,
1940
+ sAppId: string,
1941
+ ownerKey: string,
1942
+ signMessage: (message: Uint8Array) => string,
1943
+ options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
1944
+ ): Promise<void> {
1945
+ this.ensureInitialized();
1946
+ log('Inserting formation invite: %s', token);
1947
+
1948
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1949
+
1950
+ // Build each bound field exactly as the deferred CHECK sees it post-coercion:
1951
+ // - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
1952
+ // the engine so it byte-matches the column's stored/coerced form.
1953
+ // - TotalUses: decimal string via String(...) (⇔ cast(new.TotalUses as text)), '' when absent.
1954
+ // - ValidationUrl: the url or '' when absent.
1955
+ // - StrandId: the host strand id or '' when absent (text column, no coercion).
1956
+ const expiresAtCanonical = options.expiresAtMs == null
1957
+ ? null
1958
+ : await canonicalDatetime(this.db!, options.expiresAtMs);
1959
+ const expiresAtField = expiresAtCanonical ?? '';
1960
+ const totalUsesField = options.totalUses == null ? '' : String(options.totalUses);
1961
+ const validationUrlField = options.validationUrl ?? '';
1962
+ const strandIdField = options.strandId ?? '';
1963
+
1964
+ // Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
1965
+ // Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
1966
+ const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
1967
+ token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
1968
+ ]);
1969
+ const signature = signMessage(message);
1970
+
1971
+ // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
1972
+ // signed source-of-truth and the stored value are produced once. StampId is a real,
1973
+ // unique column (single-use anti-replay), no longer a context value.
1974
+ await this.execWrite(`
1975
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
1976
+ with context OwnerKey = ?, Signature = ?
1977
+ values (?, ?, ?, ?, ?, ?, ?)
1978
+ `, [
1979
+ ownerKey, signature,
1980
+ token, sAppId,
1981
+ expiresAtCanonical,
1982
+ options.totalUses ?? null,
1983
+ options.validationUrl ?? null,
1984
+ options.strandId ?? null,
1985
+ stampId,
1986
+ ], 'formation-invite-insert');
1987
+
1988
+ log('Formation invite inserted: %s', token);
1989
+ }
1990
+
1991
+ /**
1992
+ * Redeem a `FormationInvite` by inserting the `Strand` row and a matching
1993
+ * `FormationUsage` row **atomically, in one transaction**.
1994
+ *
1995
+ * The two CHECK constraints are mutually circular under immediate evaluation:
1996
+ * `Strand.AuthorizedInsert`'s consent branch requires a `FormationUsage` row naming
1997
+ * this strand's `(Id, StampId)`, while `FormationUsage.StrandExists` requires a
1998
+ * `Strand` row matching that same pair — the ONE freshly-minted `strandStampId` below
1999
+ * satisfies both, which is what binds the consent record to this specific strand ROW
2000
+ * (so a later owner-signed, tombstoned removal cannot be undone by re-inserting the
2001
+ * id with a fresh stamp). Both CHECKs contain
2002
+ * subqueries, so Quereus auto-defers them to transaction commit — wrapping both
2003
+ * inserts in a single explicit `begin … commit` lets both deferred CHECKs see
2004
+ * both rows at commit. The strand is authorised WITHOUT an owner signature
2005
+ * (the `FormationUsage` branch of `Strand.AuthorizedInsert`) but still gets a fresh,
2006
+ * unique `StampId` column to satisfy the not-null/unique anti-replay column.
2007
+ *
2008
+ * The seated strand is always open (`'o'`) and keyless — the consent branch of
2009
+ * `Strand.AuthorizedInsert` accepts nothing else, the invite must be UNBOUND
2010
+ * (`FormationInvite.StrandId` null; a bound invite's host strand is owner-provisioned
2011
+ * and only ever record-only, see {@link recordFormationUsage}), and a given strand id
2012
+ * may be consent-seated once, EVER: after an owner-signed removal, re-joining that id
2013
+ * takes an owner re-seat ({@link insertStrand}) plus a bound invite, never another
2014
+ * redemption.
2015
+ *
2016
+ * The usage row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so
2017
+ * concurrent redemptions of one token never contend for a shared row key — there is no
2018
+ * lost race to retry and no second trip through the approval hook. The invite's seat
2019
+ * budget is checked by COUNT ({@link assertSeatRemains}) inside the write lock, ahead of
2020
+ * the write, so a spent invite is refused by name (`InvitationExhaustedError`) instead of
2021
+ * as the schema cap clause's generic `Authorized` CHECK failure.
2022
+ */
2023
+ async redeemInvitation(params: {
2024
+ token: string;
2025
+ strandId: string;
2026
+ /** The joining peer's own ed25519 public key. Required: it is inside BOTH signed digests (see {@link formationVouchMessage} / {@link formationConsentMessage}). */
2027
+ peerKey: string;
2028
+ disclosure?: string;
2029
+ /** Single-use nonce for this redemption, minted by the JOINER; both signed digests cover it. */
2030
+ usageStampId: string;
2031
+ /** The joiner's signature over the `'consent'` digest — verified by the `PeerConsented` CHECK. */
2032
+ peerSignature: string;
2033
+ nowMs?: number;
2034
+ validationKey?: string;
2035
+ validationSignature?: string;
2036
+ /**
2037
+ * Aborted when the caller has given up. Checked once PER ATTEMPT, inside the write lock,
2038
+ * before that attempt's transaction is opened — never between the two inserts — throwing
2039
+ * {@link FormationAbortedError} with the invite unspent.
2040
+ */
2041
+ signal?: AbortSignal;
2042
+ /**
2043
+ * The invite's seat budget, when the caller already has it in hand (e.g. it already read
2044
+ * the `FormationInvite` row to get here). Passed through to {@link assertSeatRemains} so
2045
+ * the seat check needs no extra read. `undefined` falls back to a fresh
2046
+ * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
2047
+ */
2048
+ totalUses?: number | null;
2049
+ }): Promise<FormationUsageResult> {
2050
+ this.ensureInitialized();
2051
+ const {
2052
+ token, strandId, disclosure = '',
2053
+ peerKey, peerSignature, usageStampId,
2054
+ nowMs, validationKey, validationSignature, signal, totalUses,
2055
+ } = params;
2056
+ log('Redeeming invitation %s -> strand %s', token, strandId);
2057
+
2058
+ const localPeerId = this.config.libp2pNode.peerId.toString();
2059
+ // A failed attempt rolls its whole transaction back, so nothing was ever persisted under
2060
+ // this stamp, and it is deliberately not part of the approver's signed digest.
2061
+ const strandStampId = generateStampId(localPeerId);
2062
+
2063
+ // Ordering inside the locked body is contractual: the abort check, then the seat check,
2064
+ // then the write — FormationAbortedError may only ever be thrown before the write is
2065
+ // issued, and a transient-failure retry (lockedWithRetry re-runs the whole body) must
2066
+ // re-check both before re-presenting the write.
2067
+ await this.lockedWithRetry(async () => {
2068
+ // Inside the lock: a write still parked behind another writer when the caller's
2069
+ // budget expired is abandoned rather than executed.
2070
+ if (signal?.aborted) {
2071
+ throw new FormationAbortedError(token, 'redemption');
2072
+ }
2073
+ await this.assertSeatRemains(token, totalUses);
2074
+ await this.inTransaction('redemption', async () => {
2075
+ // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
2076
+ // still carrying a fresh unique StampId for the anti-replay column.
2077
+ // Hard-coded open + keyless: the consent branch admits no other shape.
2078
+ await this.db!.exec(`
2079
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
2080
+ with context OwnerKey = null, Signature = null
2081
+ values (?, 'o', null, ?)
2082
+ `, [strandId, strandStampId]);
2083
+
2084
+ // 2. FormationUsage row — authorised by the matching FormationInvite, and
2085
+ // carrying the strand's stamp so it authorizes THIS row and no other.
2086
+ await this.execFormationUsageInsert({
2087
+ token, usageStampId, disclosure, strandId, strandStampId,
2088
+ peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
2089
+ validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
2090
+ });
2091
+ });
2092
+ }, {}, 'formation-usage');
2093
+
2094
+ log('Redeemed invitation %s -> strand %s (stamp %s)', token, strandId, usageStampId);
2095
+ return { usageStampId };
2096
+ }
2097
+
2098
+ /**
2099
+ * Record a `FormationUsage` against an **already-existing** `Strand` (no strand
2100
+ * insert). This is the redemption path when the strand was provisioned
2101
+ * separately (e.g. owner-signed) and the consent record is added after the
2102
+ * fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
2103
+ * is satisfied by the pre-existing committed strand row. Echoes back the
2104
+ * redemption's `usageStampId` (the row's primary key).
2105
+ *
2106
+ * Use {@link redeemInvitation} instead when the strand must be created by
2107
+ * consent atomically with the usage.
2108
+ *
2109
+ * The strand's live `StampId` is read first and written onto the usage row:
2110
+ * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
2111
+ * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
2112
+ * record authorizes exactly the strand ROW it was recorded against. A missing strand
2113
+ * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
2114
+ * ordinary "host strand has not converged yet" case is already reported as `missing`
2115
+ * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
2116
+ * point is a genuine race and deserves a named error, not a silent rollback.
2117
+ *
2118
+ * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
2119
+ * path — the one production actually races on (a bound invite published by `cadre-web` /
2120
+ * `cadre-phone` is always record-only) — never contends with another redemption for a
2121
+ * shared row key: there is no lost race to retry and no second trip through the approval
2122
+ * hook. The invite's seat budget is checked by COUNT ({@link assertSeatRemains}) inside
2123
+ * the write lock, ahead of the write.
2124
+ */
2125
+ async recordFormationUsage(params: {
2126
+ token: string;
2127
+ strandId: string;
2128
+ /** The joining peer's own ed25519 public key. Required: it is inside BOTH signed digests (see {@link formationVouchMessage} / {@link formationConsentMessage}). */
2129
+ peerKey: string;
2130
+ disclosure?: string;
2131
+ /** Single-use nonce for this redemption, minted by the JOINER; both signed digests cover it. */
2132
+ usageStampId: string;
2133
+ /** The joiner's signature over the `'consent'` digest — verified by the `PeerConsented` CHECK. */
2134
+ peerSignature: string;
2135
+ nowMs?: number;
2136
+ validationKey?: string;
2137
+ validationSignature?: string;
2138
+ /**
2139
+ * Aborted when the caller has given up. Checked inside the write lock, before the insert
2140
+ * is issued, throwing {@link FormationAbortedError} with the invite unspent. Never
2141
+ * checked once the insert has been issued.
2142
+ */
2143
+ signal?: AbortSignal;
2144
+ /**
2145
+ * The invite's seat budget, when the caller already has it in hand (e.g. it already read
2146
+ * the `FormationInvite` row to get here). Passed through to {@link assertSeatRemains} so
2147
+ * the seat check needs no extra read. `undefined` falls back to a fresh
2148
+ * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
2149
+ */
2150
+ totalUses?: number | null;
2151
+ }): Promise<FormationUsageResult> {
2152
+ this.ensureInitialized();
2153
+ const {
2154
+ token, strandId, disclosure = '',
2155
+ peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal,
2156
+ totalUses,
2157
+ } = params;
2158
+
2159
+ // The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
2160
+ // consent record authorizes.
2161
+ const strandStampId = await this.queryStrandStampId(strandId);
2162
+ if (strandStampId === null) {
2163
+ throw new MissingHostStrandError(strandId, token);
2164
+ }
2165
+
2166
+ // Ordering inside the locked body is contractual — abort check, seat check, write — see
2167
+ // the matching note in redeemInvitation.
2168
+ await this.lockedWithRetry(async () => {
2169
+ if (signal?.aborted) {
2170
+ throw new FormationAbortedError(token, 'usage recording');
2171
+ }
2172
+ await this.assertSeatRemains(token, totalUses);
2173
+ await this.execFormationUsageInsert({
2174
+ token, usageStampId, disclosure, strandId, strandStampId,
2175
+ peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
2176
+ validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
2177
+ });
2178
+ }, {}, 'formation-usage');
2179
+
2180
+ log('Recorded formation usage: token=%s strand=%s (stamp %s)', token, strandId, usageStampId);
2181
+ return { usageStampId };
2182
+ }
2183
+
2184
+ /**
2185
+ * Refuse a redemption that would consume a seat the invite does not have.
2186
+ *
2187
+ * The schema's count-based cap clause (`FormationUsage.Authorized`) refuses an over-limit
2188
+ * write at the database anyway — but it fails as a generic `CHECK constraint failed:
2189
+ * Authorized`, which the manager reports as a retryable conflict. Catching it here as
2190
+ * {@link InvitationExhaustedError} lets the joiner be told the invitation is spent instead
2191
+ * of being sent around a loop that can never close. Runs inside the write lock, so on a
2192
+ * same-node race the loser reads the winner's committed row and is refused by name.
2193
+ *
2194
+ * `knownTotalUses` lets a caller that already read the `FormationInvite` row (both
2195
+ * production callers in `ControlFormationUsageRecorder` do) skip a second read on the common,
2196
+ * non-racing path. `undefined` falls back to a fresh read here.
2197
+ *
2198
+ * NOTE: a passed-in budget is a value read BEFORE the write lock (and, for a validating
2199
+ * invite, before an outbound approval call). Safe today because `FormationInvite` is
2200
+ * insert/delete only (its `Immutable` constraint), so the only way to stale it is an owner
2201
+ * revoking the token and re-issuing it with MORE seats mid-redemption; if invites ever gain
2202
+ * an update path, drop the parameter and read here instead.
2203
+ */
2204
+ private async assertSeatRemains(
2205
+ token: string,
2206
+ knownTotalUses: number | null | undefined,
2207
+ ): Promise<void> {
2208
+ // A missing invite is left to `Authorized` — it is not an exhaustion, and the CHECK's
2209
+ // rejection is already the right, non-retryable answer for it. That fallback is also what
2210
+ // makes this read's failure mode safe: it is a full-primary-key point lookup
2211
+ // (`FormationInvite where Token = ?`), the shape whose reliability on a networked strand
2212
+ // is still open (tracked by `debt-composite-pk-point-lookup-unreliable-untracked`). A
2213
+ // spurious empty result only costs the attempt its NAMED exhaustion error, reverting it to
2214
+ // today's generic `Authorized` refusal — never a seat the invite does not have.
2215
+ const totalUses = knownTotalUses !== undefined
2216
+ ? knownTotalUses
2217
+ : (await this.queryFormationInvite(token))?.totalUses ?? null;
2218
+ if (totalUses == null) {
2219
+ return;
2220
+ }
2221
+ const used = await this.countFormationUsage(token);
2222
+ if (used >= totalUses) {
2223
+ throw new InvitationExhaustedError(token, used, totalUses);
2224
+ }
2225
+ }
2226
+
2227
+ /** Parameterised `FormationUsage` insert shared by redeem + record paths. */
2228
+ private async execFormationUsageInsert(opts: {
2229
+ token: string;
2230
+ /** Single-use nonce bound into the approver's signed digest; the row's primary key, so one approval spends once. */
2231
+ usageStampId: string;
2232
+ disclosure: string;
2233
+ strandId: string;
2234
+ /** The live `Strand.StampId` this consent record authorizes (`StrandExists` matches the pair). */
2235
+ strandStampId: string;
2236
+ peerKey: string;
2237
+ peerSignature: string;
2238
+ nowMs: number;
2239
+ validationKey: string | null;
2240
+ validationSignature: string | null;
2241
+ }): Promise<void> {
2242
+ // Derive `context.Now` through the same `canonicalDatetime` transform that
2243
+ // produced the stored `ExpiresAt`, so the deferred CHECK's `FI.ExpiresAt >
2244
+ // context.Now` compares two byte-identical engine-`datetime` strings. The
2245
+ // previous `new Date(nowMs).toISOString()` form differed only by a trailing
2246
+ // `.000Z` (the engine `datetime()` separator is `T`, not a space), which never
2247
+ // flipped the strict `>` against a second-granular `ExpiresAt` — so this is a
2248
+ // robustness/consistency change, matching the strand layer's `consumeInvite`,
2249
+ // not a fix for an observable mis-ordering.
2250
+ const nowCanonical = await canonicalDatetime(this.db!, opts.nowMs);
2251
+ await this.db!.exec(`
2252
+ insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
2253
+ with context Now = ?, ValidationKey = ?, ValidationSignature = ?
2254
+ values (?, ?, ?, ?, ?, ?, ?)
2255
+ `, [
2256
+ nowCanonical, opts.validationKey, opts.validationSignature,
2257
+ opts.token, opts.usageStampId, opts.peerKey,
2258
+ opts.peerSignature, opts.disclosure, opts.strandId, opts.strandStampId,
2259
+ ]);
2260
+ }
2261
+
2262
+ /**
2263
+ * Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
2264
+ * the parsed epoch-ms of the stored `datetime` (null when the invite never
2265
+ * expires); the caller compares it against the wall clock for freshness.
2266
+ */
2267
+ async queryFormationInvite(token: string): Promise<{
2268
+ token: string;
2269
+ sAppId: string;
2270
+ expiresAtMs: number | null;
2271
+ totalUses: number | null;
2272
+ validationUrl: string | null;
2273
+ strandId: string | null;
2274
+ } | null> {
2275
+ this.ensureInitialized();
2276
+ for await (const row of this.db!.eval(
2277
+ 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?',
2278
+ [token]
2279
+ )) {
2280
+ return {
2281
+ token: row.Token as string,
2282
+ sAppId: row.sAppId as string,
2283
+ expiresAtMs: parseNullableStoredDatetimeMs(row.ExpiresAt as string | number | null),
2284
+ totalUses: (row.TotalUses as number | null) ?? null,
2285
+ validationUrl: (row.ValidationUrl as string | null) ?? null,
2286
+ strandId: (row.StrandId as string | null) ?? null,
2287
+ };
2288
+ }
2289
+ return null;
2290
+ }
2291
+
2292
+ /**
2293
+ * Count `FormationUsage` rows recorded against a token (uses consumed so far).
2294
+ *
2295
+ * NOTE: `Token` left the primary key in `formation-unique-token-redesign` and gained the
2296
+ * `FormationUsageByToken` secondary index, so this filter is now served by an index SEEK
2297
+ * (a descent through the index collection) where it used to be a leading-key partial match
2298
+ * the optimystic vtab declined and the engine served by scan. That matters because the seat
2299
+ * cap is this count: a descent that under-reports admits a seat the invite does not have,
2300
+ * where a scan could not. Descent misses have been observed on a NETWORKED store (tracked
2301
+ * by `debt-composite-pk-point-lookup-unreliable-untracked`, for full-primary-key lookups);
2302
+ * no miss has been observed on this index on a SINGLE node. Across nodes the index is
2303
+ * currently blind: a sibling never learns index entries for rows written elsewhere (the
2304
+ * distributed commit drops the index update — measured 2026-08-12, tracked by
2305
+ * `blocked/secondary-index-seek-blind-to-sibling-rows`), so on a multi-machine party this
2306
+ * count under-reports and the cap over-admits until the upstream fix lands. If
2307
+ * over-admission ever shows up on a converged single node — a token with more usage rows
2308
+ * than its `TotalUses` and no concurrent redeemers to explain it — suspect this seek first
2309
+ * and drop the index to fall back to the scan.
2310
+ */
2311
+ async countFormationUsage(token: string): Promise<number> {
2312
+ this.ensureInitialized();
2313
+ for await (const row of this.db!.eval(
2314
+ 'select count(1) as Count from CadreControl.FormationUsage where Token = ?',
2315
+ [token]
2316
+ )) {
2317
+ return (row.Count as number) ?? 0;
2318
+ }
2319
+ return 0;
2320
+ }
2321
+
2322
+ /**
2323
+ * Is any `FormationInvite` row still redeemable — unexpired AND with usage
2324
+ * below its `TotalUses`? A null `ExpiresAt` never expires and a null
2325
+ * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
2326
+ * per-token semantics (`isTokenValid` / `isTokenUsed`).
2327
+ *
2328
+ * Answers the control-network connection gate's coarse "does this node expect
2329
+ * a stranger?" question, which has no token to ask about. The expiry
2330
+ * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
2331
+ * invite the formation handler would reject can never hold the gate open.
2332
+ *
2333
+ * The scan is deliberately not pushed into SQL: nothing else here compares a
2334
+ * stored `datetime` with an inequality, so the parse stays in JS via the
2335
+ * shared {@link parseNullableStoredDatetimeMs}. Only invites that are
2336
+ * unexpired AND use-metered cost a {@link countFormationUsage} read, and an
2337
+ * unlimited-use invite anywhere in the scan short-circuits all of them.
2338
+ */
2339
+ async hasOutstandingFormationInvite(nowMs: number = Date.now()): Promise<boolean> {
2340
+ this.ensureInitialized();
2341
+ // NOTE: scans every FormationInvite row (expired ones included) on the
2342
+ // stranger path of an inbound connection. Cadre-scale invite counts make
2343
+ // that free today; if a long-lived cadre accumulates thousands of expired
2344
+ // invites and inbound upgrades slow down, add an expiry-ordered index or
2345
+ // prune redeemed/expired rows.
2346
+ const metered: Array<{ token: string; totalUses: number }> = [];
2347
+ let unlimitedOutstanding = false;
2348
+ for await (const row of this.db!.eval(
2349
+ 'select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite'
2350
+ )) {
2351
+ const expiresAtMs = parseNullableStoredDatetimeMs(row.ExpiresAt as string | number | null);
2352
+ if (expiresAtMs !== null && expiresAtMs <= nowMs) {
2353
+ continue;
2354
+ }
2355
+ const totalUses = (row.TotalUses as number | null) ?? null;
2356
+ if (totalUses === null) {
2357
+ unlimitedOutstanding = true;
2358
+ } else {
2359
+ metered.push({ token: row.Token as string, totalUses });
2360
+ }
2361
+ }
2362
+ if (unlimitedOutstanding) {
2363
+ return true;
2364
+ }
2365
+ for (const invite of metered) {
2366
+ if (await this.countFormationUsage(invite.token) < invite.totalUses) {
2367
+ return true;
2368
+ }
2369
+ }
2370
+ return false;
2371
+ }
2372
+
2373
+ /**
2374
+ * Close the database and cleanup resources.
2375
+ *
2376
+ * Drains the local-write chain first: {@link withWriteLock} can park a write behind
2377
+ * others across an await, and a queued closure evaluates `this.db!` only when it
2378
+ * finally runs — so nulling the handle out from under it would throw a TypeError on a
2379
+ * null handle instead of committing. The tail never rejects (it swallows both
2380
+ * outcomes), so the bare await is safe.
2381
+ *
2382
+ * NOTE: this makes `close()` wait on a stuck write. Acceptable today — every locked
2383
+ * body is a bounded local `exec` — but revisit (bounded drain, or abandon after a
2384
+ * deadline) if a write can ever hang.
2385
+ */
2386
+ async close(): Promise<void> {
2387
+ await this.writeQueue;
2388
+ if (this.collectionFactory) {
2389
+ await this.collectionFactory.shutdown();
2390
+ this.collectionFactory = null;
2391
+ }
2392
+ if (this.db) {
2393
+ void this.db.close();
2394
+ this.db = null;
2395
+ }
2396
+ this.initialized = false;
2397
+ log('ControlDatabase closed');
2398
+ }
2399
+
2400
+ private ensureInitialized(): void {
2401
+ if (!this.initialized || !this.db) {
2402
+ throw new Error('ControlDatabase not initialized. Call initialize() first.');
2403
+ }
2404
+ }
2405
+ }
2406
+