@serfab/cadre-core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +255 -253
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +1121 -81
  9. package/dist/cadre-node.js +2276 -262
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-cohort.d.ts +26 -0
  15. package/dist/control-cohort.js +26 -0
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +959 -54
  18. package/dist/control-database.js +1421 -148
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +90 -10
  21. package/dist/control-formation-recorder.js +130 -14
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +651 -72
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +161 -0
  30. package/dist/control-write-retry.js +412 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +11 -11
  36. package/dist/device-token.js +14 -13
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +26 -0
  39. package/dist/ed25519-key.js +56 -1
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +17 -7
  54. package/dist/index.js +40 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/membership-connection-gater.d.ts +146 -0
  60. package/dist/membership-connection-gater.js +180 -0
  61. package/dist/membership-connection-gater.js.map +1 -0
  62. package/dist/node-local-snapshot.d.ts +109 -0
  63. package/dist/node-local-snapshot.js +182 -0
  64. package/dist/node-local-snapshot.js.map +1 -0
  65. package/dist/peer-addr-book.d.ts +92 -0
  66. package/dist/peer-addr-book.js +220 -0
  67. package/dist/peer-addr-book.js.map +1 -0
  68. package/dist/peer-authorization.d.ts +135 -18
  69. package/dist/peer-authorization.js +152 -21
  70. package/dist/peer-authorization.js.map +1 -1
  71. package/dist/peer-record.d.ts +21 -8
  72. package/dist/peer-record.js +26 -10
  73. package/dist/peer-record.js.map +1 -1
  74. package/dist/relay-addrs.d.ts +45 -0
  75. package/dist/relay-addrs.js +103 -0
  76. package/dist/relay-addrs.js.map +1 -0
  77. package/dist/relay-reservation.d.ts +266 -0
  78. package/dist/relay-reservation.js +613 -0
  79. package/dist/relay-reservation.js.map +1 -0
  80. package/dist/seed-bootstrap.d.ts +228 -56
  81. package/dist/seed-bootstrap.js +340 -198
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +47 -21
  84. package/dist/seed-trust-policy.js +24 -15
  85. package/dist/seed-trust-policy.js.map +1 -1
  86. package/dist/strand-addr-protocol.d.ts +42 -13
  87. package/dist/strand-addr-protocol.js +58 -52
  88. package/dist/strand-addr-protocol.js.map +1 -1
  89. package/dist/strand-backfill.d.ts +109 -0
  90. package/dist/strand-backfill.js +279 -0
  91. package/dist/strand-backfill.js.map +1 -0
  92. package/dist/strand-cohort.d.ts +4 -37
  93. package/dist/strand-cohort.js +4 -15
  94. package/dist/strand-cohort.js.map +1 -1
  95. package/dist/strand-database.d.ts +11 -16
  96. package/dist/strand-database.js +19 -7
  97. package/dist/strand-database.js.map +1 -1
  98. package/dist/strand-formation-manager.d.ts +36 -7
  99. package/dist/strand-formation-manager.js +115 -15
  100. package/dist/strand-formation-manager.js.map +1 -1
  101. package/dist/strand-formation-protocol.d.ts +103 -4
  102. package/dist/strand-formation-protocol.js +232 -4
  103. package/dist/strand-formation-protocol.js.map +1 -1
  104. package/dist/strand-instance-manager.d.ts +88 -19
  105. package/dist/strand-instance-manager.js +178 -34
  106. package/dist/strand-instance-manager.js.map +1 -1
  107. package/dist/strand-member-registry.d.ts +5 -5
  108. package/dist/strand-member-registry.js +18 -10
  109. package/dist/strand-member-registry.js.map +1 -1
  110. package/dist/strand-membership-writer.d.ts +484 -88
  111. package/dist/strand-membership-writer.js +751 -128
  112. package/dist/strand-membership-writer.js.map +1 -1
  113. package/dist/strand-solicitation.d.ts +99 -33
  114. package/dist/strand-solicitation.js +100 -52
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-transport-key.d.ts +40 -0
  117. package/dist/strand-transport-key.js +56 -0
  118. package/dist/strand-transport-key.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +9 -7
  120. package/dist/strand-wake-protocol.js +22 -46
  121. package/dist/strand-wake-protocol.js.map +1 -1
  122. package/dist/strand-watcher.d.ts +17 -2
  123. package/dist/strand-watcher.js +47 -3
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/trusted-owner-store-file.d.ts +12 -0
  126. package/dist/trusted-owner-store-file.js +39 -0
  127. package/dist/trusted-owner-store-file.js.map +1 -0
  128. package/dist/trusted-owner-store.d.ts +64 -0
  129. package/dist/trusted-owner-store.js +126 -0
  130. package/dist/trusted-owner-store.js.map +1 -0
  131. package/dist/types.d.ts +307 -44
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -1
  134. package/package.json +20 -8
  135. package/src/arachnode-stub.ts +159 -159
  136. package/src/bootstrap-peer-store-file.ts +44 -0
  137. package/src/bootstrap-peer-store.ts +172 -0
  138. package/src/cadre-node.ts +5158 -2981
  139. package/src/canonical-json.ts +30 -30
  140. package/src/control-authorization.ts +114 -0
  141. package/src/control-cohort.ts +130 -103
  142. package/src/control-database.ts +2406 -948
  143. package/src/control-formation-recorder.ts +305 -151
  144. package/src/control-schema.ts +651 -72
  145. package/src/control-stream.ts +205 -128
  146. package/src/control-write-retry.ts +483 -0
  147. package/src/delegate-admission.ts +302 -0
  148. package/src/device-token.ts +94 -89
  149. package/src/diagnostics/connection-path.ts +277 -277
  150. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  151. package/src/ed25519-key.ts +66 -1
  152. package/src/enrollment.ts +199 -199
  153. package/src/file-durable-slot.ts +61 -0
  154. package/src/formation-approval.ts +604 -0
  155. package/src/fs-atomic.ts +107 -0
  156. package/src/hibernation-manager.ts +437 -437
  157. package/src/identity-key.ts +109 -0
  158. package/src/index.ts +137 -5
  159. package/src/key-store-file.ts +132 -203
  160. package/src/key-store.ts +99 -99
  161. package/src/membership-connection-gater.ts +204 -0
  162. package/src/node-local-snapshot.ts +260 -0
  163. package/src/peer-addr-book.ts +250 -0
  164. package/src/peer-authorization.ts +259 -81
  165. package/src/peer-record.ts +164 -146
  166. package/src/push-credentials.ts +84 -84
  167. package/src/push-node.ts +64 -64
  168. package/src/push-notifier-apns.ts +247 -247
  169. package/src/push-notifier-fcm.ts +220 -220
  170. package/src/push-notifier-shared.ts +28 -28
  171. package/src/push-notifier.ts +52 -52
  172. package/src/relay-addrs.ts +111 -0
  173. package/src/relay-reservation.ts +823 -0
  174. package/src/seed-bootstrap.ts +409 -209
  175. package/src/seed-trust-policy.ts +140 -107
  176. package/src/strand-addr-protocol.ts +441 -410
  177. package/src/strand-backfill.ts +366 -0
  178. package/src/strand-cohort.ts +38 -77
  179. package/src/strand-database.ts +202 -204
  180. package/src/strand-formation-manager.ts +491 -368
  181. package/src/strand-formation-protocol.ts +692 -391
  182. package/src/strand-instance-manager.ts +201 -46
  183. package/src/strand-member-key.ts +39 -39
  184. package/src/strand-member-registry.ts +182 -177
  185. package/src/strand-membership-writer.ts +938 -146
  186. package/src/strand-solicitation.ts +472 -407
  187. package/src/strand-transport-key.ts +58 -0
  188. package/src/strand-wake-payload.ts +32 -32
  189. package/src/strand-wake-protocol.ts +314 -331
  190. package/src/strand-watcher.ts +314 -251
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +1309 -1032
@@ -1,7 +1,10 @@
1
1
  import { Database } from '@quereus/quereus';
2
+ import type { SqlParameters } from '@quereus/quereus';
2
3
  import type { Libp2p } from '@libp2p/interface';
3
4
  import type { IRepo } from '@optimystic/db-core';
4
- import type { StrandRow, PeerAddressRecord, DeviceTokenRecord } from './types.js';
5
+ import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow } from './types.js';
6
+ import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
7
+ export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
5
8
  /**
6
9
  * Generate a unique stamp ID for transaction authorization.
7
10
  * Format: 32 bytes base64url encoded
@@ -9,35 +12,187 @@ import type { StrandRow, PeerAddressRecord, DeviceTokenRecord } from './types.js
9
12
  * - Last 16 bytes: Random bytes (for collision resistance)
10
13
  */
11
14
  export declare function generateStampId(peerId: string): string;
15
+ /**
16
+ * A `FormationUsage` was to be recorded against a host `Strand` row that is not
17
+ * present locally.
18
+ *
19
+ * Consent is bound to the strand's one-off `StampId` (see the consent branch of
20
+ * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
21
+ * The ordinary "host strand has not converged on this responder yet" case is caught
22
+ * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
23
+ * reaching here means the row vanished between that check and the write, which is a
24
+ * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
25
+ * CHECK at commit (which would drop the write with a far less legible error).
26
+ */
27
+ export declare class MissingHostStrandError extends Error {
28
+ readonly strandId: string;
29
+ readonly token: string;
30
+ constructor(strandId: string, token: string);
31
+ }
32
+ /**
33
+ * A formation write was abandoned because the caller's `AbortSignal` fired BEFORE the
34
+ * `FormationUsage` insert was issued — the invite's single use is NOT spent.
35
+ *
36
+ * Thrown by the formation write paths ({@link ControlDatabase.recordFormationUsage},
37
+ * {@link ControlDatabase.redeemInvitation}) and by `ControlFormationUsageRecorder`'s
38
+ * pre-approval checks when the responder's provisioning budget expires while the work is
39
+ * still queued (e.g. behind the write lock) or still asking the approval hook. Never
40
+ * thrown once the insert has been issued: a half-issued write must be allowed to land,
41
+ * and the formation listener's settle grace then adopts it as a successful join. The
42
+ * manager rethrows this error instead of mapping it to a retryable conflict — the
43
+ * listener's timeout path owns the reply.
44
+ */
45
+ export declare class FormationAbortedError extends Error {
46
+ readonly token: string;
47
+ constructor(token: string, operation: string, options?: {
48
+ cause?: unknown;
49
+ });
50
+ }
51
+ /**
52
+ * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
53
+ * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
54
+ * seat.
55
+ *
56
+ * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
57
+ * the write, inside {@link ControlDatabase.redeemInvitation} /
58
+ * {@link ControlDatabase.recordFormationUsage} — and off nothing else. It exists because
59
+ * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
60
+ * schema's own count-based cap clause, which the manager reports as a retryable
61
+ * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
62
+ * Two redemptions of one token racing on the SAME node serialize behind the local write
63
+ * queue, so the loser reads the winner's committed row and is refused here by name —
64
+ * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
65
+ * ran before either redemption wrote anything.
66
+ */
67
+ export declare class InvitationExhaustedError extends Error {
68
+ readonly token: string;
69
+ readonly usesRecorded: number;
70
+ readonly totalUses: number;
71
+ constructor(token: string, usesRecorded: number, totalUses: number);
72
+ }
73
+ /**
74
+ * What a recorded `FormationUsage` row came out as.
75
+ *
76
+ * `usageStampId` is echoed back so a caller that obtained an approver sign-off can prove the
77
+ * nonce it signed over is the one that landed — the two must match or
78
+ * `FormationUsage.Authorized` rejects the row (see {@link formationVouchMessage}).
79
+ */
80
+ export interface FormationUsageResult {
81
+ /** The single-use nonce written to `UsageStampId` (the row's primary key) — the caller's, or a freshly minted one. */
82
+ usageStampId: string;
83
+ }
12
84
  /**
13
85
  * Build the canonical authorization message that owner signatures are bound to.
14
86
  *
15
- * The message is a SINGLE framed SHA-256 digest over the ordered tuple of fields (the
16
- * crypto plugin's injective multi-field encoding), in a fixed field order, with the
17
- * single-use StampId as the final field:
87
+ * The message is a SINGLE framed SHA-256 digest over the ordered field vector from
88
+ * {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
89
+ * encoding): two fixed literals — the domain tag naming the table rule and the action
90
+ * tag — followed by the row fields in the schema's fixed order, with the single-use
91
+ * StampId as the final field where the table has one:
18
92
  *
19
- * message = sha256(encodeFields([field_1, field_2, ..., StampId])) // raw digest bytes
93
+ * message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
20
94
  *
21
95
  * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
22
96
  * verify the identical bytes with one variadic call
23
- * (`verify(digest(field_1, ..., StampId), context.Signature, A.Key, 'ed25519')`): SQL
24
- * `digest(...)` returns the base64url string of the same digest, which `verify`'s default
97
+ * (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
98
+ * SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
25
99
  * base64url input encoding decodes back to those raw bytes — so signer and verifier
26
100
  * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
27
101
  * `cast(... as text)` / `coalesce(...,'')`; the TS args are strings), so the per-field
28
- * type tags agree. This binds the signature to the row contents (not a bare stamp),
29
- * closing the captured-stamp replay / privilege-escalation hole. Single source of truth:
30
- * every signed writer (and every test/harness signer) MUST build the message through this
31
- * function in the schema's field order, or `verify` will reject the row.
102
+ * type tags agree. Binding the row contents closes captured-stamp replay; the leading
103
+ * domain/action tags scope the signature to ONE table rule, so an approval minted for
104
+ * one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
105
+ * longer double as an OwnerKey enrollment). Single source of truth: every signed writer
106
+ * (and every test/harness signer) MUST build the message through this function with the
107
+ * schema's tags and field order, or `verify` will reject the row.
108
+ */
109
+ export declare function buildAuthorizationMessage(domain: ControlDomain, action: ControlAction, rowFields: string[]): Uint8Array;
110
+ /**
111
+ * The exact bytes an outside approver signs to authorize ONE redemption of a
112
+ * `ValidationUrl`-bearing `FormationInvite` — the TS mirror of the `'vouch'` digest in
113
+ * `FormationUsage.Authorized`, in the schema's field order.
114
+ *
115
+ * The approval covers the whole redemption: the invitation (`token`), the single-use nonce
116
+ * the JOINING peer minted for THIS redemption (`usageStampId`), the network being joined
117
+ * (`strandId`), the joining peer's own ed25519 public key (`peerKey`), and the disclosure
118
+ * text. That makes it non-transferable — an approval cannot be re-presented for another use
119
+ * of the same invitation, another network, or another joiner — and the nonce's `unique`
120
+ * column makes a verbatim re-presentation a duplicate-row rejection. The joiner mints the
121
+ * nonce ({@link generateStampId}) and sends it in its contact message; sign these bytes over
122
+ * that same nonce, then pass BOTH the nonce and the signature to
123
+ * {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
124
+ * signing one nonce and inserting another fails the CHECK.
125
+ */
126
+ export declare function formationVouchMessage(fields: {
127
+ token: string;
128
+ usageStampId: string;
129
+ strandId: string;
130
+ peerKey: string;
131
+ disclosure: string;
132
+ }): Uint8Array;
133
+ /**
134
+ * The exact bytes the JOINING peer signs to consent to ONE redemption of a
135
+ * `FormationInvite` — the TS mirror of the `'consent'` digest in the schema's
136
+ * `FormationUsage.PeerConsented` constraint, in its field order.
137
+ *
138
+ * A sibling of {@link formationVouchMessage}, not a replacement: the approver's vouch
139
+ * says the redemption may proceed, this says the joiner itself agreed to it, and the
140
+ * two are signed by different keys over the same nonce so neither can stand in for
141
+ * the other. Verify the result with `peer-authorization.ts`'s
142
+ * `verifyFormationConsent`, whose `formationConsentDigest` is the base64url twin of
143
+ * this vector — the two must stay in lockstep.
144
+ *
145
+ * `strandId` is deliberately absent, unlike its vouch sibling: the joiner cannot know
146
+ * the strand when it signs (a bound invite's host strand arrives only in the result
147
+ * frame; an unbound strand is minted by the responder). The responder cannot
148
+ * substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
149
+ * and an unbound redemption mints a fresh strand, so there is no victim to name.
32
150
  */
33
- export declare function buildAuthorizationMessage(fields: string[]): Uint8Array;
151
+ export declare function formationConsentMessage(fields: {
152
+ token: string;
153
+ usageStampId: string;
154
+ peerKey: string;
155
+ disclosure: string;
156
+ }): Uint8Array;
34
157
  /**
35
- * Names of the CadreControl tables that {@link ControlDatabase.countRows} can
36
- * read. Constraining to this union keeps the dynamic `from` clause off the
37
- * SQL-injection surface and hands callers (e.g. the integration harness's
38
- * `waitForControlSync`) a typo-proof table argument.
158
+ * Guarded tables a node may reap locally once their tombstone has committed — the
159
+ * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
160
+ * `CadrePeer.AuthorizedDelete` in the schema). `Strand` is deliberately absent: its row
161
+ * carries `MemberPrivateKey`, the party's own membership secret for that network, stored
162
+ * nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any future change).
163
+ * `OwnerKey` has no production removal path and `MinOneOwner` makes an automated
164
+ * owner-key reap a party-bricking hazard.
39
165
  */
40
- export type ControlTable = 'OwnerKey' | 'ValidationKey' | 'Strand' | 'CadrePeer' | 'DeviceToken' | 'FormationInvite' | 'FormationUsage';
166
+ export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey"];
167
+ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
168
+ /**
169
+ * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
170
+ *
171
+ * The one hook the party-membership snapshot a node admits control-DB traffic
172
+ * against ({@link CadreNode.refreshMembershipGate}) hangs off, so that snapshot
173
+ * is refreshed by the WRITE rather than by whoever remembered to ask. `reason`
174
+ * only labels the log line. Must not reject (the notifier swallows and logs
175
+ * anyway — a committed write never fails because a snapshot refresh did).
176
+ */
177
+ export type MembershipChangeListener = (reason: string) => Promise<void>;
178
+ /**
179
+ * Identity of the tombstone a committed guarded delete wrote — a
180
+ * {@link RevocationRow} minus its `ReissuedAt` counter, which a fresh tombstone
181
+ * always seats at 0 (`FreshTombstone`) and so carries no information.
182
+ */
183
+ export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
184
+ /**
185
+ * Notified after a guarded-table delete — and the `CadreControl.Revocation`
186
+ * tombstone it writes in the same transaction — has COMMITTED.
187
+ *
188
+ * The seam the write-while-alone re-replication queue hangs off
189
+ * ({@link CadreNode.noteGuardedDelete}): one listener covers all four guarded
190
+ * tables (`CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`) because every
191
+ * owner delete funnels through {@link deleteGuardedRow}. Synchronous — the
192
+ * handler only records the tombstone's identity; it must not throw (the notifier
193
+ * swallows and logs anyway — a committed delete never fails because bookkeeping did).
194
+ */
195
+ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
41
196
  export interface ControlDatabaseConfig {
42
197
  /** Party ID for the control network */
43
198
  partyId: string;
@@ -61,6 +216,17 @@ export declare class ControlDatabase {
61
216
  private collectionFactory;
62
217
  private readonly config;
63
218
  private initialized;
219
+ private membershipListener;
220
+ private guardedDeleteListener;
221
+ /** Tail of the local-write chain — see {@link withWriteLock}. */
222
+ private writeQueue;
223
+ /**
224
+ * Pacing seams for {@link lockedWithRetry}. Production leaves this empty (real backoff,
225
+ * real clock); specs inject a recorded `sleep` / fake `now` so no test waits out a real
226
+ * backoff. Applied OVER the call site's policy, so it overrides pacing without a spec
227
+ * having to restate the policy's attempts or classifier.
228
+ */
229
+ private controlWriteRetryPacing;
64
230
  constructor(config: ControlDatabaseConfig);
65
231
  /**
66
232
  * Initialize the database - load schema and register plugins
@@ -81,7 +247,7 @@ export declare class ControlDatabase {
81
247
  /**
82
248
  * Count rows in a CadreControl table as seen by THIS database instance.
83
249
  *
84
- * `table` is validated against {@link CONTROL_TABLES} before it is interpolated
250
+ * `table` is validated against {@link CONTROL_TABLE_SET} before it is interpolated
85
251
  * into the `from` clause: the names are not user input, but the check keeps the
86
252
  * dynamic query off the injection surface and fails loudly on a typo instead of
87
253
  * emitting a malformed statement. The count reflects only the rows this node's
@@ -116,25 +282,111 @@ export declare class ControlDatabase {
116
282
  getOwnerKeys(): Promise<Set<string>>;
117
283
  /**
118
284
  * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
285
+ * Includes the persisted voucher columns, which
286
+ * {@link CadreNode.listAuthorizedMembers} re-checks against its node-local anchor.
287
+ *
288
+ * Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded HERE, so
289
+ * every membership reader inherits the exclusion — a revoked peer is neither
290
+ * authorizable nor addressable (a replayed row planted at a retired stamp must not
291
+ * be RPC'd or handed out as an address). Reads the retired set through
292
+ * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
293
+ * that seam to model the replicated live-row-plus-tombstone merge state.
294
+ *
295
+ * NOTE: runs a second query (the retired-stamp set) per call, and this sits on the
296
+ * membership-gate refresh path; control tables are a handful of rows today, so this
297
+ * is cheap — if they ever grow, fold the exclusion into one statement or cache the
298
+ * retired-stamp set.
299
+ *
300
+ * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
301
+ * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
302
+ * which feed the node rows directly. Nothing enforces that the copies stay true, so a
303
+ * change to the filter's SHAPE here (what it keys on, which table it consults) must be
304
+ * carried into both, or those suites keep passing against a contract the database no
305
+ * longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
306
+ * second dimension, give the fakes a shared fixture or a contract test instead.
119
307
  */
120
- queryCadrePeers(): Promise<Array<{
121
- peerId: string;
122
- multiaddr: string | null;
123
- stampId: string | null;
124
- vouchOwner: string | null;
125
- vouchSig: string | null;
126
- }>>;
308
+ queryCadrePeers(): Promise<CadrePeerRow[]>;
127
309
  /**
128
- * Read a single `CadrePeer` row's single-use `StampId` nonce (null when the row
129
- * does not exist). Used by the owner delete / re-touch paths, which must bind
130
- * their signature to the row's CURRENT nonce ({@link cadrePeerRemoveDigest} /
131
- * {@link cadrePeerVoucherDigest}).
310
+ * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
311
+ * set a `ValidationUrl` redemption's approval signature must be signed by (see
312
+ * {@link ControlFormationUsageRecorder}, which checks one key at a time via
313
+ * {@link queryValidationKeyStampId}).
314
+ *
315
+ * Sorted in TypeScript rather than SQL so the order is stable regardless of storage
316
+ * order; the enrolled set is a handful of keys, so the sort is free.
317
+ */
318
+ queryValidationKeys(): Promise<string[]>;
319
+ /**
320
+ * Read one guarded row's single-use `StampId` nonce (null when the row does not
321
+ * exist). Every owner-signed delete / re-touch path must bind its signature to the
322
+ * row's CURRENT nonce, so they all read through here first.
323
+ *
324
+ * Deliberately RAW — no `Revocation` filtering (unlike {@link queryCadrePeers} /
325
+ * {@link queryPeerRecord}): {@link deleteGuardedRow} and the insert-if-absent guard
326
+ * in the `CadrePeer` upsert need to see a physically present row. Filtering here
327
+ * would make an insert-if-absent guard read "absent" for a row that exists and
328
+ * collide on the primary key.
329
+ *
330
+ * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
331
+ * come from closed literal unions — no caller-supplied string can reach the statement
332
+ * (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
333
+ */
334
+ private queryStampId;
335
+ /**
336
+ * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
337
+ * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
338
+ * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
339
+ * straight to {@link queryStampId}.
132
340
  */
133
341
  queryCadrePeerStampId(peerId: string): Promise<string | null>;
342
+ /**
343
+ * `DeviceToken` stamp nonce. {@link deleteDeviceToken} reads the stamp itself, so this
344
+ * is the reader for callers that need to observe a live row's nonce — asserting a token
345
+ * was seated, or that a removal retired the stamp it named.
346
+ */
347
+ queryDeviceTokenStampId(peerId: string): Promise<string | null>;
348
+ /** `Strand` stamp nonce — bound into {@link deleteStrand}'s remove digest. */
349
+ queryStrandStampId(strandId: string): Promise<string | null>;
350
+ /** `ValidationKey` stamp nonce — bound into {@link deleteValidationKey}'s remove digest. */
351
+ queryValidationKeyStampId(key: string): Promise<string | null>;
352
+ /**
353
+ * Collect the retired `StampId` nonces recorded in `CadreControl.Revocation` for one
354
+ * {@link RevocableTable}. A stamp
355
+ * lands here when its row is removed ({@link SeedBootstrapService.removePeer},
356
+ * {@link deleteValidationKey}, {@link deleteStrand}), and retirement is permanent —
357
+ * rows are never deleted (only their `ReissuedAt` counter moves, via
358
+ * {@link reissueRevocations}). Read-side mitigation for the write-time race: the
359
+ * schema's `NotRevoked` CHECK only sees locally visible tombstones, so a node that
360
+ * converged on a resurrected row before its tombstone can hold both; readers
361
+ * ({@link queryCadrePeers}, {@link queryPeerRecord},
362
+ * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
363
+ *
364
+ * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
365
+ * gate request while the table only ever grows. Cheap today (a cadre removes peers
366
+ * rarely); if removals ever become routine, cache the set and invalidate it on write.
367
+ */
368
+ queryRevokedStamps(tableName: RevocableTable): Promise<Set<string>>;
369
+ /**
370
+ * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
371
+ * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
372
+ * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
373
+ * it. Plain scan with no `where`, so the composite-primary-key point-lookup hazard
374
+ * (see the statement comment in {@link reissueRevocations}) does not arise. Unlocked,
375
+ * like every other read.
376
+ */
377
+ queryRevocations(): Promise<RevocationRow[]>;
134
378
  /**
135
379
  * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
136
380
  *
137
- * Returns null when no row exists. Missing/legacy column values are coalesced
381
+ * Returns null when no row exists, or when the row's `StampId` is retired in
382
+ * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
383
+ * ({@link CadreNode.resolvePeerAddrs}) never hands out a revoked peer's addresses.
384
+ * The stamp is read for that check only and then dropped; the returned
385
+ * {@link PeerAddressRecord} shape is unchanged. Reads the retired set through
386
+ * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
387
+ * that seam (see {@link queryCadrePeers}).
388
+ *
389
+ * Missing/legacy column values are coalesced
138
390
  * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
139
391
  * verify/freshness gates uniformly reject an unpublished or malformed row.
140
392
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
@@ -151,6 +403,19 @@ export declare class ControlDatabase {
151
403
  * is the refresh path for any member — owner or drone — once its row
152
404
  * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
153
405
  * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
406
+ *
407
+ * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
408
+ * is not: it only ever touches THIS node's own row (sole caller
409
+ * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
410
+ * of the membership snapshot, so it cannot change that snapshot. It also runs on the
411
+ * periodic self-registration refresh, where a notify would add a recurring membership
412
+ * read for nothing.
413
+ *
414
+ * Its INSERT counterpart (`SeedBootstrapService.insertSelfPeerRecord`) does notify. Not an
415
+ * inconsistency: it shares the one owner-signed insert path with every other member's
416
+ * row, and a self insert happens once at startup, so the wasted refresh is a single read
417
+ * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
418
+ * path is worth exempting.
154
419
  */
155
420
  updateSelfPeerRecord(record: PeerAddressRecord): Promise<void>;
156
421
  /**
@@ -161,8 +426,12 @@ export declare class ControlDatabase {
161
426
  * gates uniformly reject an unpublished or malformed row. `platform` is returned
162
427
  * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
163
428
  * the self-signature, which covers the platform field).
429
+ *
430
+ * `stampId` rides along ({@link DeviceTokenRow}) because the resolver must drop a
431
+ * row whose stamp is retired in `CadreControl.Revocation`; it is NOT part of
432
+ * {@link DeviceTokenRecord}, which is the self-signed shape the peer's `Sig` covers.
164
433
  */
165
- queryDeviceToken(peerId: string): Promise<DeviceTokenRecord | null>;
434
+ queryDeviceToken(peerId: string): Promise<DeviceTokenRow | null>;
166
435
  /**
167
436
  * Apply a peer's own self-signed device-token update to an existing row.
168
437
  *
@@ -195,6 +464,25 @@ export declare class ControlDatabase {
195
464
  * @param memberPrivateKey - Optional private key for membership in closed strands
196
465
  */
197
466
  insertStrand(strandId: string, type: 'o' | 'c', ownerKey: string, signMessage: (message: Uint8Array) => string, memberPrivateKey?: string): Promise<void>;
467
+ /**
468
+ * Delete a strand from the control database using an owner signature.
469
+ *
470
+ * Mirrors {@link insertStrand}'s row-bound approach for the delete half: the owner
471
+ * signs the canonical `'remove'`-tagged authorization message over (Id, StampId) — the
472
+ * schema's `Strand.AuthorizedDelete` verifies this DISTINCT digest, so the insert
473
+ * approval (which never expires) can never be replayed as a removal. The delete and
474
+ * the `Revocation` tombstone retiring the row's StampId commit in ONE transaction —
475
+ * `Strand.RevocationRecorded` refuses a bare delete, and without the tombstone the
476
+ * stamp would free up and the original formation approval could re-seat the strand.
477
+ * Body shared with every other guarded delete via {@link deleteGuardedRow}.
478
+ *
479
+ * The remove digest binds only (Id, StampId) — not Type/MemberPrivateKey — so this
480
+ * works identically for open and closed strands.
481
+ *
482
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
483
+ * when a row was actually removed.
484
+ */
485
+ deleteStrand(strandId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
198
486
  /**
199
487
  * Insert a validation key into the control database using an owner signature.
200
488
  *
@@ -209,22 +497,513 @@ export declare class ControlDatabase {
209
497
  * with the owner's private key, returning a base64url signature
210
498
  */
211
499
  insertValidationKey(key: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<void>;
500
+ /**
501
+ * Delete a validation key from the control database using an owner signature.
502
+ *
503
+ * Mirrors {@link insertValidationKey}'s row-bound approach for the delete half: the
504
+ * owner signs the canonical `'remove'`-tagged authorization message over (Key,
505
+ * StampId) — the schema's `ValidationKey.AuthorizedDelete` verifies this DISTINCT
506
+ * digest, so the enrollment approval (which never expires) can never be replayed as a
507
+ * removal. The delete and the `Revocation` tombstone retiring the row's StampId commit
508
+ * in ONE transaction — `ValidationKey.RevocationRecorded` refuses a bare delete, and
509
+ * without the tombstone the stamp would free up and the original enrollment approval
510
+ * could re-seat the key. Body shared with every other guarded delete via
511
+ * {@link deleteGuardedRow}.
512
+ *
513
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
514
+ * when a row was actually removed.
515
+ */
516
+ deleteValidationKey(key: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
517
+ /**
518
+ * Owner-vouched INSERT of one `CadrePeer` membership row, notifying the membership
519
+ * listener once it has committed.
520
+ *
521
+ * Mints a fresh single-use `StampId` and signs the `'vouch'`-tagged authorization
522
+ * message over (PeerId, StampId) — see {@link buildAuthorizationMessage} — satisfying
523
+ * the schema's `CadrePeer.AuthorizedInsert`. Binding the peer id to the nonce means a
524
+ * captured insert approval cannot be replayed (live rows are blocked by the unique
525
+ * `StampId` column, removed rows by `Revocation` retirement via `CadrePeer.NotRevoked`)
526
+ * and cannot be repurposed as a delete (which signs a distinct `'remove'`-tagged digest).
527
+ *
528
+ * The owner signature does NOT cover the address columns — those are vouched only as far
529
+ * as the owner asserts them, and a peer's own `Sig` (when present) is what makes the row
530
+ * resolvable.
531
+ *
532
+ * The {@link mutateCadrePeer} wrapper lives here rather than in the caller so no
533
+ * `CadrePeer` inserter can forget it — the write itself is what lets the admitted peer's
534
+ * traffic in.
535
+ *
536
+ * Idempotent on an already-present row: two writers can legitimately race the SAME peer's
537
+ * first row — the node's own background self-publish ({@link CadreNode.registerSelf})
538
+ * against a foreground {@link SeedBootstrapService.authorizePeer} of that node's id — and
539
+ * the write lock only serializes them; the loser would hit the `CadrePeer.PeerId` UNIQUE
540
+ * constraint. The existence check runs INSIDE the locked body, so it sees the winner's
541
+ * committed row (a pre-lock check would re-open the read-then-insert window). The existing
542
+ * row — voucher, addresses, self-`Sig` — is left untouched; re-touching a live row is
543
+ * {@link reauthorizeCadrePeer}'s job.
544
+ *
545
+ * @param row - the membership row's columns; `multiaddr` is the comma-joined address list.
546
+ * @param ownerKey - public key of the vouching owner, persisted into `VouchOwner`.
547
+ * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
548
+ * private key, returning a base64url signature.
549
+ * @returns `true` when this call performed the INSERT, `false` when the in-lock existence
550
+ * check found the row already seated. The loser needs to know: an authorize seats a row
551
+ * with a null `Sig`, so a self-publish that lost the race must fall through to a
552
+ * self-update or its record never lands.
553
+ */
554
+ insertCadrePeer(row: {
555
+ peerId: string;
556
+ publicKey: string | null;
557
+ multiaddr: string;
558
+ updatedAt: number;
559
+ sig: string | null;
560
+ }, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
561
+ /**
562
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
563
+ * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
564
+ * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
565
+ * the row is re-emitted as a fresh, broadcasting transaction.
566
+ *
567
+ * This is the write-while-alone re-replication primitive: a membership row that committed
568
+ * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
569
+ * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
570
+ * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
571
+ * and voucher change — `PublicKey` / `Multiaddr` / `Sig` are left intact — so it is safe
572
+ * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
573
+ * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
574
+ * `UpdatedAt` without re-signing would invalidate its self-signature).
575
+ *
576
+ * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
577
+ * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
578
+ * membership snapshot cannot have changed.
579
+ *
580
+ * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
581
+ * that removes the row in between makes the `update` match nothing, and this still returns
582
+ * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
583
+ * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
584
+ * re-add in the same window fails loudly instead, since the signature binds the retired
585
+ * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
586
+ * into the locked body.
587
+ *
588
+ * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
589
+ * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
590
+ * change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
591
+ * beats a per-method exception the next reader has to relearn.
592
+ *
593
+ * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
594
+ * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
595
+ * reader's node-local anchor. Benign today because the only caller — the write-while-alone
596
+ * drain — re-touches solely rows this node itself authored (`pendingPeerWrites`), so the
597
+ * voucher is rewritten to the key that already signed it. If a future path ever lets one
598
+ * owner re-touch a row a DIFFERENT owner vouched, the voucher silently flips: readers that
599
+ * anchor the original owner but not this one would drop a legitimate member. Such a path
600
+ * must re-vouch deliberately (or preserve the existing VouchOwner/VouchSig) rather than
601
+ * inherit this rebinding.
602
+ *
603
+ * @param peerId - the membership row to re-touch.
604
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
605
+ * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
606
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
607
+ * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
608
+ */
609
+ reauthorizeCadrePeer(peerId: string, updatedAt: number, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
610
+ /**
611
+ * Owner-signed removal of one `CadrePeer` membership row, notifying the membership
612
+ * listener once it has committed.
613
+ *
614
+ * Mirrors {@link deleteStrand} for the membership table: the owner signs the DISTINCT
615
+ * `'remove'`-tagged digest over (PeerId, StampId), so the row's stored `VouchSig` can
616
+ * never be replayed to delete it, and the `Revocation` tombstone retiring the stamp
617
+ * lands in the SAME transaction — `CadrePeer.RevocationRecorded` refuses a bare delete,
618
+ * and without the tombstone the never-expiring admission approval (which the removed
619
+ * peer holds a copy of) could re-seat the row.
620
+ *
621
+ * The {@link mutateCadrePeer} wrapper lives here rather than in the caller so no
622
+ * `CadrePeer` remover can forget it. It notifies whenever the body resolves, including
623
+ * the absent-row no-op below — a caller that must not notify for an already-absent peer
624
+ * gates on {@link queryCadrePeerStampId} first (see
625
+ * {@link SeedBootstrapService.removePeer}).
626
+ *
627
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
628
+ * when a row was actually removed.
629
+ */
630
+ deleteCadrePeer(peerId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
631
+ /**
632
+ * Owner-signed removal of one peer's `DeviceToken` row (logout / token invalidation).
633
+ *
634
+ * Mirrors {@link deleteStrand}: the owner signs the DISTINCT `'remove'`-tagged digest
635
+ * over (PeerId, StampId), so the insert approval can never be replayed to clear a token,
636
+ * and the `Revocation` tombstone retiring the stamp lands in the SAME transaction —
637
+ * `DeviceToken.RevocationRecorded` refuses a bare delete, and without the tombstone the
638
+ * never-expiring insert approval (which the cleared device holds a copy of) could
639
+ * re-seat the token.
640
+ *
641
+ * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
642
+ * when a row was actually removed.
643
+ */
644
+ deleteDeviceToken(peerId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
645
+ /**
646
+ * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
647
+ * stamp, in ONE transaction. The single body behind EVERY guarded delete —
648
+ * {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
649
+ * {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
650
+ * of those is a thin named wrapper, so callers never pass a table name and this
651
+ * generic shape stays off the public surface.
652
+ *
653
+ * `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
654
+ * production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
655
+ * requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
656
+ * it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
657
+ * needs no change.
658
+ *
659
+ * The row's CURRENT stamp is read first and signed over, so the remove digest binds to
660
+ * this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
661
+ * reported as `false` so a caller can tell "removed" from "was never there" without a
662
+ * second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
663
+ * which must not fire for a no-op).
664
+ *
665
+ * NOTE: the stamp read is outside the transaction. A concurrent writer that removes
666
+ * the row in between makes the signature bind a stamp that is no longer live; the
667
+ * delete then matches nothing and the tombstone insert collides with the other
668
+ * writer's on `Revocation`'s (TableName, StampId) primary key, so the transaction
669
+ * fails rather than silently half-applying. If concurrent owner-device removals ever
670
+ * become routine, fold the stamp read into the transaction instead.
671
+ *
672
+ * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
673
+ * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
674
+ *
675
+ * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
676
+ * come from closed literal unions — no caller-supplied string reaches the statement.
677
+ */
678
+ private deleteGuardedRow;
679
+ /**
680
+ * Delete one guarded row that an ALREADY-COMMITTED `Revocation` tombstone retires —
681
+ * the local catch-up for a node that converged on a removal's tombstone while still
682
+ * holding the removed row. That live-row-plus-tombstone state is reachable only by
683
+ * replication merge (local writes cannot build it: `Revocation.RowIsGone` refuses a
684
+ * tombstone while the row is live), and replication cannot carry the delete itself —
685
+ * replaying a delete of a row that is already gone locally is a no-op — so without
686
+ * this the stale row is permanent garbage on every node that held it at revocation
687
+ * time. Authorized by the REAP branch of the table's `AuthorizedDelete`: the
688
+ * tombstone is itself owner-signed (`Revocation.Authorized`), so no owner key is
689
+ * needed HERE — any node holding row + tombstone may reap, drones included.
690
+ *
691
+ * Returns whether a row was removed: `false` without writing when the row is absent
692
+ * locally (the common case on most nodes), or when its live stamp is not `stampId`
693
+ * (a fresh incarnation the owner re-seated, which the old tombstone must not touch).
694
+ *
695
+ * The delete's WHERE clause binds `StampId` too — required, not belt-and-braces: an
696
+ * owner may re-seat the row (fresh stamp) between the stamp read below and the
697
+ * statement, and without the clause the reap would delete the owner's brand-new row.
698
+ * With it the statement matches nothing and the reap is a silent no-op (the schema
699
+ * branch would also refuse that delete, but as a constraint error thrown into a
700
+ * background sweep — matched-nothing is the better failure mode). Like
701
+ * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
702
+ * harmless — nothing was deleted, and the next sweep re-reads.
703
+ *
704
+ * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
705
+ * committed tombstone that authorizes the reap, so a reap files no second tombstone.
706
+ * It therefore does NOT fire the guarded-delete listener either — that seam exists to
707
+ * queue a tombstone for re-issue when it committed while alone, and a reap has no
708
+ * tombstone of its own to re-issue. A reap that commits while alone needs no
709
+ * re-replication at all: every OTHER node either already lacks the row, or holds the
710
+ * same committed tombstone and reaps its own copy.
711
+ *
712
+ * NOTE: the reap delete is keyed on the primary key; the `StampId` predicate is applied
713
+ * where the statement runs. If a reap on one node ever has to be reconciled against an
714
+ * owner re-seat of the same key that landed on another node, whether the delete or the
715
+ * re-seat wins is decided by the collection's merge order, not by this clause — the same
716
+ * ordering question the owner-signed delete path already carries
717
+ * (`tickets/blocked/forked-control-collection-sync-livelocks.md`). Only matters once a
718
+ * sweep drives this automatically on connected nodes; if that reconcile ever drops a
719
+ * freshly re-seated row, this is the site.
720
+ *
721
+ * `table` and its {@link GUARDED_KEY_COLUMN} column come from closed literal unions —
722
+ * no caller-supplied string reaches the statement (same injection-surface discipline
723
+ * as {@link deleteGuardedRow}). The `with context` clause is PRESENT and bound to
724
+ * nulls, never omitted: Quereus cannot resolve `context.OwnerKey` at plan time when
725
+ * the clause is absent while a constraint references `context.*` (nulls through a
726
+ * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
727
+ *
728
+ * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
729
+ * reconcile pass runs while the node has at least one control connection.
730
+ */
731
+ reapRevokedRow(table: ReapableTable, rowKey: string, stampId: string): Promise<boolean>;
732
+ /**
733
+ * Sweep every locally-held `Revocation` tombstone and {@link reapRevokedRow} the guarded
734
+ * row each one retires, returning how many rows were actually removed.
735
+ *
736
+ * This is the enumeration half of the reap: replication carries the tombstone but cannot
737
+ * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
738
+ * that converged on a removal while holding the removed row keeps that row as inert
739
+ * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
740
+ * control-cohort reconcile pass is that something, gated on the node having at least one
741
+ * control connection (a reap is a write, and a write committed alone is local-only).
742
+ *
743
+ * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
744
+ * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
745
+ * re-entrant, so this loop must not hold it. Same structure as
746
+ * `CadreNode.reissueAuthoredMembershipRows`.
747
+ *
748
+ * Two rows are deliberately left alone:
749
+ *
750
+ * - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `OwnerKey`) — no reap branch
751
+ * exists on their `AuthorizedDelete`, so the delete would throw rather than no-op.
752
+ * - **This node's OWN `CadrePeer` / `DeviceToken` row** (`selfPeerId`). Reaping it would
753
+ * fight this node's own re-registration: `registerSelf` / `retouchSelfDeviceToken` run
754
+ * on the heartbeat and the growth drain, and after a self-reap their insert-if-absent
755
+ * guard takes the INSERT path — which needs an owner signature `NotRevoked` refuses for
756
+ * a retired stamp, and which a revoked drone cannot produce at all. The trade is
757
+ * recurring failure noise every cycle against removing the one stale row nobody else
758
+ * reads from this node (every other node already filters it by tombstone). A revoked
759
+ * node therefore keeps its own copy of its own row; "a node that learns it has been
760
+ * revoked should shut itself down" is a distinct behaviour and its own ticket.
761
+ * `ValidationKey` has no self notion and is not special-cased.
762
+ *
763
+ * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
764
+ * starve every tombstone after the failing one on every subsequent pass, and each row is
765
+ * independent. Returns the count so far if the database is closed mid-sweep.
766
+ *
767
+ * `selfPeerId` is a parameter rather than read from the injected libp2p node so the
768
+ * skip-self rule is exercisable without one.
769
+ *
770
+ * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
771
+ * one {@link queryStampId} point lookup per tombstone, and the empty-table early return
772
+ * makes the common case (a party that has never revoked anyone) a single scan per pass.
773
+ * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
774
+ * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
775
+ * party; if the table ever grows, persist a node-local high-water mark of what has
776
+ * already been reaped instead of re-scanning everything (the same bound the sweep in
777
+ * `CadreNode.drainPendingRevocations` wants).
778
+ */
779
+ reapRevokedRows(selfPeerId: string): Promise<number>;
780
+ /**
781
+ * Owner-signed re-issue of a batch of `Revocation` tombstones: bump each row's
782
+ * `ReissuedAt` to `reissuedAt` in ONE transaction. Re-writing the row is what makes
783
+ * the storage layer re-broadcast it — a tombstone that committed while the node was
784
+ * alone is local-only, and unlike an insert a delete cannot be replayed (the guarded
785
+ * row is already gone locally), so the tombstone is the half of a removal that can
786
+ * still carry it to the rest of the party.
787
+ *
788
+ * One transaction, not one per row: a sweep may cover every tombstone in the table,
789
+ * and each separate commit is a separate round of network work. One owner signature
790
+ * per row: `AuthorizedReissue`'s digest binds (TableName, RowKey, StampId,
791
+ * ReissuedAt). `reissuedAt` must be STRICTLY above every affected row's current
792
+ * value (`ReissueOnly`); callers derive it as `Math.max(Date.now(), max(existing) + 1)`,
793
+ * mirroring {@link CadreNode.reissuePeerAuthorize}'s monotonic bump. Re-issuing a
794
+ * tombstone whose guarded row was never present locally is the normal case (a node
795
+ * that converged on the tombstone but never held the row) — a re-issue files no
796
+ * delete, so `RowIsGone` / `RevocationRecorded` are not involved.
797
+ *
798
+ * A constraint failure on any row rolls the WHOLE batch back and propagates —
799
+ * {@link lockedWithRetry}'s classifier only re-presents transient cluster failures,
800
+ * so a real refusal (non-owner signer, or a stale counter when two owner devices
801
+ * sweep concurrently and the loser's `ReissueOnly` CHECK fails) surfaces to the
802
+ * caller, which retries with a fresh counter on its next sweep. A row whose
803
+ * `StampId` matches nothing locally is a silent no-op statement; callers enumerate
804
+ * via {@link queryRevocations} first, so they only name stamps they hold.
805
+ *
806
+ * Returns how many UPDATE statements ran (`rows.length` on success).
807
+ */
808
+ reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<number>;
809
+ /**
810
+ * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
811
+ *
812
+ * A failed `commit()` has already torn the transaction down, so the `rollback()` in
813
+ * the failure path would itself throw "No transaction active" and mask the real
814
+ * cause — that secondary throw is logged and swallowed, and the original error always
815
+ * propagates.
816
+ *
817
+ * It opens a transaction unconditionally (Quereus hard-throws on a nested
818
+ * `beginTransaction`), so a caller already inside one must not call it. A `CadrePeer`
819
+ * write nests this INSIDE {@link mutateCadrePeer}, never the reverse — see
820
+ * {@link assertCommitBoundary}.
821
+ *
822
+ * Private: every multi-statement control write lives in this class (the owner-signed
823
+ * delete/tombstone pairs all go through {@link deleteGuardedRow}), so needing to widen
824
+ * this is a sign the writer that wants it belongs in here too.
825
+ *
826
+ * Deliberately NOT wrapped in {@link withWriteLock}: callers already hold the
827
+ * (non-re-entrant) lock, so taking it again here would self-deadlock.
828
+ *
829
+ * @param label - What the transaction was doing, for the rollback log line.
830
+ */
831
+ private inTransaction;
832
+ /**
833
+ * Wire (or clear, with null) the single listener notified after every committed
834
+ * `CadreControl.CadrePeer` row write.
835
+ *
836
+ * At most one listener: a ControlDatabase instance belongs to exactly one
837
+ * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
838
+ * second call replaces the first rather than fanning out, so a stale node can
839
+ * never keep receiving notifications for a database it no longer owns.
840
+ */
841
+ setMembershipChangeListener(listener: MembershipChangeListener | null): void;
842
+ /**
843
+ * Wire (or clear, with null) the single listener notified after every committed
844
+ * guarded-table delete. Same ownership contract as
845
+ * {@link setMembershipChangeListener}: one CadreNode, one listener, a second
846
+ * call replaces the first.
847
+ */
848
+ setGuardedDeleteListener(listener: GuardedDeleteListener | null): void;
849
+ /**
850
+ * Run a `CadrePeer` row mutation and notify the membership listener once it has
851
+ * COMMITTED.
852
+ *
853
+ * EVERY `CadrePeer` writer goes through here, with one documented exception
854
+ * ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
855
+ * the party-membership snapshot refresh automatic rather than a caller obligation. A
856
+ * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
857
+ * event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
858
+ * `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
859
+ * callbacks wired), so this is the one point on the write path that cannot be bypassed.
860
+ *
861
+ * `body` owns its own transaction if it needs one and must COMMIT before returning, so
862
+ * the notification never makes the listener read uncommitted state. A throwing `body`
863
+ * propagates and does NOT notify: nothing changed.
864
+ *
865
+ * That contract is enforced, not merely documented — see {@link assertCommitBoundary}.
866
+ *
867
+ * Runs under {@link withWriteLock}, notification included: with the lock held through
868
+ * the notify, no other local writer can have a transaction open while the listener
869
+ * reads, so the listener always sees exactly the committed state it was told about.
870
+ * The listener itself only READS (it re-materializes the membership snapshot) — a
871
+ * listener that wrote through a locked method would deadlock.
872
+ *
873
+ * Reaches the lock through {@link lockedWithRetry}, so after a transient cluster
874
+ * failure the WHOLE locked body re-runs — `body` must be atomic and re-runnable (the
875
+ * contract on {@link withWriteLock}). The notify sits after a successful `body` inside
876
+ * the same attempt and a throwing attempt never reaches it, so a retried mutation
877
+ * still notifies exactly once: on the attempt that commits, and not at all on
878
+ * exhaustion.
879
+ *
880
+ * @param reason - Label for the log line only (e.g. `'peer-insert'`).
881
+ */
882
+ mutateCadrePeer<T>(reason: string, body: () => Promise<T>): Promise<T>;
883
+ /**
884
+ * Run one local write under the database-wide write lock, serializing it against
885
+ * every other local writer on this ControlDatabase.
886
+ *
887
+ * Quereus tracks transaction state per `Database` (`getAutocommit()`), and a write
888
+ * statement's implicit transaction stays open across the awaits inside `exec`. Two
889
+ * local writers interleaving mid-statement therefore either trip
890
+ * {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
891
+ * open transaction — a torn commit if either side rolls back. This lock closes that
892
+ * class: every public write method of this class runs its statement(s) under it, and
893
+ * `SeedBootstrapService` wraps its direct `db.exec` writes in it too. The race is
894
+ * real, not theoretical: the background self-record publish on a control connection
895
+ * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
896
+ * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
897
+ * assert.
898
+ *
899
+ * Reads are deliberately not locked: they take no transaction of their own, and
900
+ * serializing them here would let a listener that reads during a notify (see
901
+ * {@link mutateCadrePeer}) deadlock. The lock is NOT re-entrant — a locked writer's
902
+ * body must never call another locked public method; the private bodies it composes
903
+ * ({@link deleteGuardedRow}, {@link inTransaction}) stay bare for exactly that reason.
904
+ *
905
+ * NOTE: re-entry fails SILENTLY and PERMANENTLY. A locked body that calls another
906
+ * locked public method (including a {@link mutateCadrePeer} body, which runs an
907
+ * arbitrary caller-supplied callback) queues behind its own tail and never resolves —
908
+ * no error, and it strands the whole write queue, not only that call. There is no
909
+ * cheap fail-fast: a "held" flag cannot tell re-entry from a legitimately queued
910
+ * concurrent writer. Compose bare private bodies instead.
911
+ *
912
+ * A locked body must also be ATOMIC and RE-RUNNABLE: the public write surface reaches
913
+ * this lock through {@link lockedWithRetry}, which re-runs the WHOLE body after a
914
+ * transient cluster failure. Atomic holds for every body today — one statement, or
915
+ * wrapped in {@link inTransaction} — so a failed attempt leaves nothing half-applied;
916
+ * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
917
+ * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
918
+ * attempt: a retry re-presents the exact signed message the first attempt presented.
919
+ */
920
+ withWriteLock<T>(fn: () => Promise<T>): Promise<T>;
921
+ /**
922
+ * {@link withWriteLock} plus the bounded transient-failure retry
923
+ * ({@link retryControlWrite}) — the wrapper the whole public write surface goes
924
+ * through, so a control write that failed because the cluster cohort did not answer is
925
+ * re-presented a moment later instead of surfacing to ~19 callers that are not
926
+ * uniformly written to retry.
927
+ *
928
+ * The retry wraps the LOCK, it does not live inside it: each attempt takes and
929
+ * releases the lock, and the backoff sleeps with NO lock held, so a write parked in
930
+ * backoff never stalls the other local writers queued behind it. Safe because every
931
+ * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
932
+ *
933
+ * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
934
+ * `initialized` is set — so it reaches this method directly rather than through
935
+ * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
936
+ * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
937
+ * rather than carving out a lockless path. It is also the ONLY caller that passes a
938
+ * `policy` — everything else takes the default, whose classifier and attempt count are
939
+ * unchanged by that call site existing.
940
+ *
941
+ * `label` names the operation in this loop's debug lines and nothing else
942
+ * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
943
+ * several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
944
+ * attributed to a write — keep new call sites labelled.
945
+ */
946
+ private lockedWithRetry;
947
+ /**
948
+ * One-statement local write, serialized by {@link withWriteLock} and retried on a
949
+ * transient cluster failure via {@link lockedWithRetry}. Prefer this over a bare
950
+ * `getDatabase().exec` so a new writer cannot forget the lock — the hazard
951
+ * {@link assertCommitBoundary} can only catch after the fact.
952
+ *
953
+ * Public so `SeedBootstrapService`'s direct `CadrePeer`/`DeviceToken` SQL writes go
954
+ * through the same seam. Must NOT be called from inside an already-locked body (the
955
+ * lock is not re-entrant); such a caller uses a bare `getDatabase().exec` instead.
956
+ *
957
+ * `label` names the write in the retry loop's debug lines (see {@link lockedWithRetry});
958
+ * optional only because it is log-only, but every caller in this repo passes one.
959
+ */
960
+ execWrite(sql: string, params?: SqlParameters, label?: string): Promise<void>;
961
+ /**
962
+ * Throw unless the database sits at a committed boundary — `getAutocommit()` is false
963
+ * exactly while a transaction is open.
964
+ *
965
+ * Both ends of a {@link mutateCadrePeer} body are checked, because either open
966
+ * transaction would notify a listener that then reads PRE-commit state and materializes
967
+ * a membership snapshot silently missing the write it was told about:
968
+ *
969
+ * - open on entry: the wrapper sits INSIDE an enclosing transaction and must be moved
970
+ * out to enclose that outer commit.
971
+ * - open on return: the body opened a transaction and never committed it.
972
+ *
973
+ * Neither is reachable from today's callers, so this is a fail-fast guard on a future
974
+ * mistake rather than a live code path.
975
+ *
976
+ * NOTE: `getAutocommit()` reports the whole `Database`, not this call. Local writers
977
+ * serialize through {@link withWriteLock}, so with every writer locked this can only
978
+ * trip on the two misuses above. A writer that bypasses the lock (a new method that
979
+ * forgets it, or raw `getDatabase().exec` outside a locked wrapper) re-opens the
980
+ * concurrent-writer window, and this assert is what catches it — it throws instead of
981
+ * silently joining the other write's transaction.
982
+ */
983
+ private assertCommitBoundary;
984
+ /**
985
+ * Best-effort notify. A listener that throws is logged and swallowed: the write has
986
+ * already committed, and it must never be reported as failed because a downstream
987
+ * snapshot refresh did. A null listener (no `CadreNode` attached — e.g. the service
988
+ * unit tests drive a bare control DB) is a silent no-op.
989
+ */
990
+ private notifyMembershipChanged;
212
991
  /**
213
992
  * Insert an owner-signed `FormationInvite` (open invitation token).
214
993
  *
215
994
  * The invite is the on-network record that later authorizes an
216
995
  * owner-signature-FREE `Strand` creation: an invited cadre peer redeems it
217
996
  * by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
218
- * which satisfies the consent branch of `Strand.Authorized`.
997
+ * which satisfies the consent branch of `Strand.AuthorizedInsert`.
219
998
  *
220
999
  * Like {@link insertStrand}/{@link insertValidationKey}, the owner signs the
221
1000
  * canonical row-bound authorization message (see {@link buildAuthorizationMessage})
222
1001
  * over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
223
1002
  * bare stamp — so the signature is bound to this invite's contents and cannot be
224
1003
  * transplanted onto an attacker-chosen row. The StampId is persisted as a unique
225
- * column for single-use anti-replay. `FormationInvite.AuthorizedAddOrRemove` gates
226
- * both insert and delete; its `coalesce(new, old)` verify binds the NEW row on
227
- * insert and the OLD row on delete.
1004
+ * column for single-use anti-replay. `FormationInvite.AuthorizedInsert` gates insert
1005
+ * over an `'add'`-tagged digest; deletes verify a DISTINCT `'remove'`-tagged digest
1006
+ * (`AuthorizedDelete`), so this insert approval can never be replayed as a revocation.
228
1007
  *
229
1008
  * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
230
1009
  * when set, a responder redeeming this token records a `FormationUsage` against that
@@ -259,51 +1038,135 @@ export declare class ControlDatabase {
259
1038
  * `FormationUsage` row **atomically, in one transaction**.
260
1039
  *
261
1040
  * The two CHECK constraints are mutually circular under immediate evaluation:
262
- * `Strand.Authorized`'s consent branch requires the `FormationUsage` row, while
263
- * `FormationUsage.StrandExists` requires the `Strand` row. Both CHECKs contain
1041
+ * `Strand.AuthorizedInsert`'s consent branch requires a `FormationUsage` row naming
1042
+ * this strand's `(Id, StampId)`, while `FormationUsage.StrandExists` requires a
1043
+ * `Strand` row matching that same pair — the ONE freshly-minted `strandStampId` below
1044
+ * satisfies both, which is what binds the consent record to this specific strand ROW
1045
+ * (so a later owner-signed, tombstoned removal cannot be undone by re-inserting the
1046
+ * id with a fresh stamp). Both CHECKs contain
264
1047
  * subqueries, so Quereus auto-defers them to transaction commit — wrapping both
265
1048
  * inserts in a single explicit `begin … commit` lets both deferred CHECKs see
266
1049
  * both rows at commit. The strand is authorised WITHOUT an owner signature
267
- * (the `FormationUsage` branch of `Strand.Authorized`) but still gets a fresh,
1050
+ * (the `FormationUsage` branch of `Strand.AuthorizedInsert`) but still gets a fresh,
268
1051
  * unique `StampId` column to satisfy the not-null/unique anti-replay column.
269
1052
  *
270
- * `UseNumber` is computed as `max(UseNumber)+1` for the token (the `Monotonic`
271
- * constraint); callers redeeming concurrently against the same token must
272
- * serialise, since the next use number is read before the insert.
1053
+ * The seated strand is always open (`'o'`) and keyless — the consent branch of
1054
+ * `Strand.AuthorizedInsert` accepts nothing else, the invite must be UNBOUND
1055
+ * (`FormationInvite.StrandId` null; a bound invite's host strand is owner-provisioned
1056
+ * and only ever record-only, see {@link recordFormationUsage}), and a given strand id
1057
+ * may be consent-seated once, EVER: after an owner-signed removal, re-joining that id
1058
+ * takes an owner re-seat ({@link insertStrand}) plus a bound invite, never another
1059
+ * redemption.
1060
+ *
1061
+ * The usage row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so
1062
+ * concurrent redemptions of one token never contend for a shared row key — there is no
1063
+ * lost race to retry and no second trip through the approval hook. The invite's seat
1064
+ * budget is checked by COUNT ({@link assertSeatRemains}) inside the write lock, ahead of
1065
+ * the write, so a spent invite is refused by name (`InvitationExhaustedError`) instead of
1066
+ * as the schema cap clause's generic `Authorized` CHECK failure.
273
1067
  */
274
1068
  redeemInvitation(params: {
275
1069
  token: string;
276
1070
  strandId: string;
277
- type?: 'o' | 'c';
278
- memberPrivateKey?: string;
1071
+ /** The joining peer's own ed25519 public key. Required: it is inside BOTH signed digests (see {@link formationVouchMessage} / {@link formationConsentMessage}). */
1072
+ peerKey: string;
279
1073
  disclosure?: string;
280
- peerId?: string;
281
- peerSignature?: string;
1074
+ /** Single-use nonce for this redemption, minted by the JOINER; both signed digests cover it. */
1075
+ usageStampId: string;
1076
+ /** The joiner's signature over the `'consent'` digest — verified by the `PeerConsented` CHECK. */
1077
+ peerSignature: string;
282
1078
  nowMs?: number;
283
1079
  validationKey?: string;
284
1080
  validationSignature?: string;
285
- }): Promise<void>;
1081
+ /**
1082
+ * Aborted when the caller has given up. Checked once PER ATTEMPT, inside the write lock,
1083
+ * before that attempt's transaction is opened — never between the two inserts — throwing
1084
+ * {@link FormationAbortedError} with the invite unspent.
1085
+ */
1086
+ signal?: AbortSignal;
1087
+ /**
1088
+ * The invite's seat budget, when the caller already has it in hand (e.g. it already read
1089
+ * the `FormationInvite` row to get here). Passed through to {@link assertSeatRemains} so
1090
+ * the seat check needs no extra read. `undefined` falls back to a fresh
1091
+ * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
1092
+ */
1093
+ totalUses?: number | null;
1094
+ }): Promise<FormationUsageResult>;
286
1095
  /**
287
1096
  * Record a `FormationUsage` against an **already-existing** `Strand` (no strand
288
1097
  * insert). This is the redemption path when the strand was provisioned
289
1098
  * separately (e.g. owner-signed) and the consent record is added after the
290
1099
  * fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
291
- * is satisfied by the pre-existing committed strand row. Returns the assigned
292
- * `UseNumber`.
1100
+ * is satisfied by the pre-existing committed strand row. Echoes back the
1101
+ * redemption's `usageStampId` (the row's primary key).
293
1102
  *
294
1103
  * Use {@link redeemInvitation} instead when the strand must be created by
295
1104
  * consent atomically with the usage.
1105
+ *
1106
+ * The strand's live `StampId` is read first and written onto the usage row:
1107
+ * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
1108
+ * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
1109
+ * record authorizes exactly the strand ROW it was recorded against. A missing strand
1110
+ * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
1111
+ * ordinary "host strand has not converged yet" case is already reported as `missing`
1112
+ * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
1113
+ * point is a genuine race and deserves a named error, not a silent rollback.
1114
+ *
1115
+ * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
1116
+ * path — the one production actually races on (a bound invite published by `cadre-web` /
1117
+ * `cadre-phone` is always record-only) — never contends with another redemption for a
1118
+ * shared row key: there is no lost race to retry and no second trip through the approval
1119
+ * hook. The invite's seat budget is checked by COUNT ({@link assertSeatRemains}) inside
1120
+ * the write lock, ahead of the write.
296
1121
  */
297
1122
  recordFormationUsage(params: {
298
1123
  token: string;
299
1124
  strandId: string;
1125
+ /** The joining peer's own ed25519 public key. Required: it is inside BOTH signed digests (see {@link formationVouchMessage} / {@link formationConsentMessage}). */
1126
+ peerKey: string;
300
1127
  disclosure?: string;
301
- peerId?: string;
302
- peerSignature?: string;
1128
+ /** Single-use nonce for this redemption, minted by the JOINER; both signed digests cover it. */
1129
+ usageStampId: string;
1130
+ /** The joiner's signature over the `'consent'` digest — verified by the `PeerConsented` CHECK. */
1131
+ peerSignature: string;
303
1132
  nowMs?: number;
304
1133
  validationKey?: string;
305
1134
  validationSignature?: string;
306
- }): Promise<number>;
1135
+ /**
1136
+ * Aborted when the caller has given up. Checked inside the write lock, before the insert
1137
+ * is issued, throwing {@link FormationAbortedError} with the invite unspent. Never
1138
+ * checked once the insert has been issued.
1139
+ */
1140
+ signal?: AbortSignal;
1141
+ /**
1142
+ * The invite's seat budget, when the caller already has it in hand (e.g. it already read
1143
+ * the `FormationInvite` row to get here). Passed through to {@link assertSeatRemains} so
1144
+ * the seat check needs no extra read. `undefined` falls back to a fresh
1145
+ * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
1146
+ */
1147
+ totalUses?: number | null;
1148
+ }): Promise<FormationUsageResult>;
1149
+ /**
1150
+ * Refuse a redemption that would consume a seat the invite does not have.
1151
+ *
1152
+ * The schema's count-based cap clause (`FormationUsage.Authorized`) refuses an over-limit
1153
+ * write at the database anyway — but it fails as a generic `CHECK constraint failed:
1154
+ * Authorized`, which the manager reports as a retryable conflict. Catching it here as
1155
+ * {@link InvitationExhaustedError} lets the joiner be told the invitation is spent instead
1156
+ * of being sent around a loop that can never close. Runs inside the write lock, so on a
1157
+ * same-node race the loser reads the winner's committed row and is refused by name.
1158
+ *
1159
+ * `knownTotalUses` lets a caller that already read the `FormationInvite` row (both
1160
+ * production callers in `ControlFormationUsageRecorder` do) skip a second read on the common,
1161
+ * non-racing path. `undefined` falls back to a fresh read here.
1162
+ *
1163
+ * NOTE: a passed-in budget is a value read BEFORE the write lock (and, for a validating
1164
+ * invite, before an outbound approval call). Safe today because `FormationInvite` is
1165
+ * insert/delete only (its `Immutable` constraint), so the only way to stale it is an owner
1166
+ * revoking the token and re-issuing it with MORE seats mid-redemption; if invites ever gain
1167
+ * an update path, drop the parameter and read here instead.
1168
+ */
1169
+ private assertSeatRemains;
307
1170
  /** Parameterised `FormationUsage` insert shared by redeem + record paths. */
308
1171
  private execFormationUsageInsert;
309
1172
  /**
@@ -321,12 +1184,54 @@ export declare class ControlDatabase {
321
1184
  } | null>;
322
1185
  /**
323
1186
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
1187
+ *
1188
+ * NOTE: `Token` left the primary key in `formation-unique-token-redesign` and gained the
1189
+ * `FormationUsageByToken` secondary index, so this filter is now served by an index SEEK
1190
+ * (a descent through the index collection) where it used to be a leading-key partial match
1191
+ * the optimystic vtab declined and the engine served by scan. That matters because the seat
1192
+ * cap is this count: a descent that under-reports admits a seat the invite does not have,
1193
+ * where a scan could not. Descent misses have been observed on a NETWORKED store (tracked
1194
+ * by `debt-composite-pk-point-lookup-unreliable-untracked`, for full-primary-key lookups);
1195
+ * no miss has been observed on this index on a SINGLE node. Across nodes the index is
1196
+ * currently blind: a sibling never learns index entries for rows written elsewhere (the
1197
+ * distributed commit drops the index update — measured 2026-08-12, tracked by
1198
+ * `blocked/secondary-index-seek-blind-to-sibling-rows`), so on a multi-machine party this
1199
+ * count under-reports and the cap over-admits until the upstream fix lands. If
1200
+ * over-admission ever shows up on a converged single node — a token with more usage rows
1201
+ * than its `TotalUses` and no concurrent redeemers to explain it — suspect this seek first
1202
+ * and drop the index to fall back to the scan.
324
1203
  */
325
1204
  countFormationUsage(token: string): Promise<number>;
326
- /** Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint. */
327
- private nextUseNumber;
328
1205
  /**
329
- * Close the database and cleanup resources
1206
+ * Is any `FormationInvite` row still redeemable — unexpired AND with usage
1207
+ * below its `TotalUses`? A null `ExpiresAt` never expires and a null
1208
+ * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
1209
+ * per-token semantics (`isTokenValid` / `isTokenUsed`).
1210
+ *
1211
+ * Answers the control-network connection gate's coarse "does this node expect
1212
+ * a stranger?" question, which has no token to ask about. The expiry
1213
+ * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
1214
+ * invite the formation handler would reject can never hold the gate open.
1215
+ *
1216
+ * The scan is deliberately not pushed into SQL: nothing else here compares a
1217
+ * stored `datetime` with an inequality, so the parse stays in JS via the
1218
+ * shared {@link parseNullableStoredDatetimeMs}. Only invites that are
1219
+ * unexpired AND use-metered cost a {@link countFormationUsage} read, and an
1220
+ * unlimited-use invite anywhere in the scan short-circuits all of them.
1221
+ */
1222
+ hasOutstandingFormationInvite(nowMs?: number): Promise<boolean>;
1223
+ /**
1224
+ * Close the database and cleanup resources.
1225
+ *
1226
+ * Drains the local-write chain first: {@link withWriteLock} can park a write behind
1227
+ * others across an await, and a queued closure evaluates `this.db!` only when it
1228
+ * finally runs — so nulling the handle out from under it would throw a TypeError on a
1229
+ * null handle instead of committing. The tail never rejects (it swallows both
1230
+ * outcomes), so the bare await is safe.
1231
+ *
1232
+ * NOTE: this makes `close()` wait on a stuck write. Acceptable today — every locked
1233
+ * body is a bounded local `exec` — but revisit (bounded drain, or abandon after a
1234
+ * deadline) if a write can ever hang.
330
1235
  */
331
1236
  close(): Promise<void>;
332
1237
  private ensureInitialized;