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