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