@serfab/cadre-core 1.13.0 → 1.15.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 (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
@@ -1,13 +1,14 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { Database, registerPlugin } from '@quereus/quereus';
3
+ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlugin } from '@quereus/quereus';
4
4
  import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
5
5
  import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
6
6
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
7
7
  import { CONTROL_SCHEMA } from './control-schema.js';
8
8
  import { canonicalDatetime } from './canonical-datetime.js';
9
- import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
10
- import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
9
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
10
+ import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
11
+ import { verifyCadreInviteRow } from './peer-authorization.js';
11
12
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
12
13
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
13
14
  import { chainMessages } from './control-retry.js';
@@ -75,13 +76,15 @@ export class FormationAbortedError extends Error {
75
76
  }
76
77
  }
77
78
  /**
78
- * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
79
- * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
80
- * seat.
79
+ * The invitation's seat budget is already spent: the count of recorded usage rows for the
80
+ * invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
81
+ * is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
82
+ * redeemed against.
81
83
  *
82
84
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
83
85
  * the write, inside {@link ControlDatabase.redeemInvitation} /
84
- * {@link ControlDatabase.recordFormationUsage}, and off the unlocked pre-check
86
+ * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
87
+ * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
85
88
  * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
86
89
  * pass — and off nothing else. It exists because
87
90
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
@@ -101,6 +104,23 @@ export class InvitationExhaustedError extends Error {
101
104
  this.name = 'InvitationExhaustedError';
102
105
  }
103
106
  }
107
+ /**
108
+ * {@link ControlDatabase.seatCadreInvite} was refused by `CadreInvite.AuthorizedInsert`: the
109
+ * row's `IssuerKey` is not an owner on THIS node, or its `IssuerSig` does not verify. The
110
+ * common cause is the first — the issuing owner's `OwnerKey` row has not replicated here yet,
111
+ * or that owner has since been removed — which is why the redemption protocol maps this to a
112
+ * retryable code; a tampered row fails the same way and simply never succeeds.
113
+ */
114
+ export class CadreInviteIssuerUnknownError extends Error {
115
+ constructor(inviteKey, issuerKey, options) {
116
+ super(`Cadre invitation ${inviteKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
117
+ this.inviteKey = inviteKey;
118
+ this.issuerKey = issuerKey;
119
+ this.name = 'CadreInviteIssuerUnknownError';
120
+ }
121
+ }
122
+ /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
123
+ const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
104
124
  /**
105
125
  * Parse a stored Quereus `datetime` value into epoch milliseconds.
106
126
  *
@@ -120,7 +140,7 @@ function parseStoredDatetimeMs(value) {
120
140
  * and "unparseable" to null — i.e. "no expiry". Shared by every `ExpiresAt`
121
141
  * reader so the NaN guard cannot drift between them.
122
142
  */
123
- function parseNullableStoredDatetimeMs(value) {
143
+ export function parseNullableStoredDatetimeMs(value) {
124
144
  if (value === null || value === undefined)
125
145
  return null;
126
146
  const parsed = parseStoredDatetimeMs(value);
@@ -132,14 +152,14 @@ function parseNullableStoredDatetimeMs(value) {
132
152
  * The message is a SINGLE framed SHA-256 digest over the ordered field vector from
133
153
  * {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
134
154
  * encoding): two fixed literals — the domain tag naming the table rule and the action
135
- * tag — followed by the row fields in the schema's fixed order, with the single-use
136
- * StampId as the final field where the table has one:
155
+ * tag — then the party id, then the row fields in the schema's fixed order, with the
156
+ * single-use StampId as the final field where the table has one:
137
157
  *
138
- * message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
158
+ * message = sha256(encodeFields([domain, action, partyId, field_1, ..., StampId])) // raw digest bytes
139
159
  *
140
160
  * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
141
161
  * verify the identical bytes with one variadic call
142
- * (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
162
+ * (`verify(digest('CadreControl.X', 'add', party_id(), field_1, ...), context.Signature, A.Key, 'ed25519')`):
143
163
  * SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
144
164
  * base64url input encoding decodes back to those raw bytes — so signer and verifier
145
165
  * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
@@ -147,12 +167,43 @@ function parseNullableStoredDatetimeMs(value) {
147
167
  * type tags agree. Binding the row contents closes captured-stamp replay; the leading
148
168
  * domain/action tags scope the signature to ONE table rule, so an approval minted for
149
169
  * one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
150
- * longer double as an OwnerKey enrollment). Single source of truth: every signed writer
151
- * (and every test/harness signer) MUST build the message through this function with the
152
- * schema's tags and field order, or `verify` will reject the row.
170
+ * longer double as an OwnerKey enrollment); the party id scopes it to ONE party, so two
171
+ * parties sharing an owner key never accept each other's approvals. `partyId` is the
172
+ * party the signer is configured for, and the SQL side reads its own through the
173
+ * node-local `party_id()` ({@link registerPartyIdFunction}). Single source of truth:
174
+ * every signed writer (and every test/harness signer) MUST build the message through
175
+ * this function with the schema's tags and field order, or `verify` will reject the row.
176
+ */
177
+ export function buildAuthorizationMessage(domain, action, partyId, rowFields) {
178
+ return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'bytes');
179
+ }
180
+ /**
181
+ * {@link buildAuthorizationMessage} for the two party-free digests (`UnboundControlDomain`):
182
+ * the same framed digest with no party id after the tags.
153
183
  */
154
- export function buildAuthorizationMessage(domain, action, rowFields) {
155
- return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes');
184
+ function buildUnboundAuthorizationMessage(domain, action, rowFields) {
185
+ return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes');
186
+ }
187
+ /**
188
+ * Register the zero-argument scalar `party_id()` on `db`: the party id this machine is
189
+ * configured for, which every party-bound `digest(...)` in the control schema puts third.
190
+ * Node-local by design — a `context` value would be the writer's choice (a cohort member
191
+ * that re-ran the statement would replay it as a literal), and a stored `Party` table would
192
+ * be replicated data the schema already treats as untrusted. A cohort member that re-runs a
193
+ * statement therefore evaluates its OWN party id, and the schema text stays identical for
194
+ * every party. Each `Database` carries its own registration, so a process serving two
195
+ * parties holds two.
196
+ *
197
+ * NOTE: flagged deterministic, so the planner may fold the call into a plan; plans are
198
+ * per `Database` today. If Quereus ever shares plans across `Database` instances, drop
199
+ * the DETERMINISTIC flag or one party's id would be folded into another's checks.
200
+ *
201
+ * `ControlDatabase.initialize` calls this before hydrating persisted schemas, because a
202
+ * warm restart parses the persisted CHECK text, which calls it. Exported for specs that
203
+ * build their own `Database` around a copy of a bound constraint.
204
+ */
205
+ export function registerPartyIdFunction(db, partyId) {
206
+ db.registerFunction(createScalarFunction({ name: 'party_id', numArgs: 0, flags: FunctionFlags.UTF8 | FunctionFlags.DETERMINISTIC, returnType: TEXT_RETURN }, () => partyId));
156
207
  }
157
208
  /**
158
209
  * The exact bytes an outside approver signs to authorize ONE redemption of a
@@ -169,9 +220,13 @@ export function buildAuthorizationMessage(domain, action, rowFields) {
169
220
  * that same nonce, then pass BOTH the nonce and the signature to
170
221
  * {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
171
222
  * signing one nonce and inserting another fails the CHECK.
223
+ *
224
+ * No party id, unlike every owner digest: the approver is sent only these five fields
225
+ * (`formation-approval.ts`), and the `token` it binds is a random secret seated in one
226
+ * party's `FormationInvite` table (the schema's `FormationUsage.Authorized` comment).
172
227
  */
173
228
  export function formationVouchMessage(fields) {
174
- return buildAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
229
+ return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
175
230
  fields.token, fields.usageStampId, fields.strandId, fields.peerKey, fields.disclosure,
176
231
  ]);
177
232
  }
@@ -191,13 +246,48 @@ export function formationVouchMessage(fields) {
191
246
  * the strand when it signs (a bound invite's host strand arrives only in the result
192
247
  * frame; an unbound strand is minted by the responder). The responder cannot
193
248
  * substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
194
- * and an unbound redemption mints a fresh strand, so there is no victim to name.
249
+ * and an unbound redemption mints a fresh strand, so there is no victim to name. The
250
+ * party id is absent for the same reason (it too arrives only in the result frame) and
251
+ * the `token` scopes the digest to one party instead, as for {@link formationVouchMessage}.
195
252
  */
196
253
  export function formationConsentMessage(fields) {
197
- return buildAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
254
+ return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
198
255
  fields.token, fields.usageStampId, fields.peerKey, fields.disclosure,
199
256
  ]);
200
257
  }
258
+ /**
259
+ * The exact bytes an owner signs to seat a `CadreInvite` row — the TS mirror of the `'add'`
260
+ * digest in `CadreInvite.AuthorizedInsert`, over the whole row in the schema's order with
261
+ * nullable columns as `''` (`cadreInviteRowFields`, shared with the base64url verifier
262
+ * `peer-authorization.ts`'s `cadreInviteAddDigest`). `expiresAt` must already be the stored
263
+ * canonical `datetime` string ({@link canonicalDatetime}), as {@link ControlDatabase.insertCadreInvite}
264
+ * produces it, or the signed segment will not match what the CHECK sees after coercion.
265
+ */
266
+ export function cadreInviteAddMessage(partyId, row) {
267
+ return buildAuthorizationMessage('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
268
+ }
269
+ /**
270
+ * The exact bytes the HOLDER of a cadre invitation signs, with the invitation's private key,
271
+ * to redeem it once — the TS mirror of the `'redeem'` digest `CadreInviteUsage.InvitePossessed`
272
+ * verifies against the invitation's public key. Binding the device and the nonce means a
273
+ * captured redemption can admit no other device and spend no other use.
274
+ */
275
+ export function cadreInviteRedeemMessage(partyId, fields) {
276
+ return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'redeem', partyId, [
277
+ fields.inviteKey, fields.usageStampId, fields.peerKey,
278
+ ]);
279
+ }
280
+ /**
281
+ * The exact bytes the DEVICE signs, with its own key, to consent to being admitted by one
282
+ * redemption — the TS mirror of the `'consent'` digest `CadreInviteUsage.PeerConsented`
283
+ * verifies against `PeerKey`. Same fields as {@link cadreInviteRedeemMessage} under a
284
+ * distinct action tag, so neither signature can stand in for the other.
285
+ */
286
+ export function cadreInviteConsentMessage(partyId, fields) {
287
+ return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'consent', partyId, [
288
+ fields.inviteKey, fields.usageStampId, fields.peerKey,
289
+ ]);
290
+ }
201
291
  /** Runtime guard for the dynamic-`from` count, over the one table list. */
202
292
  const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
203
293
  /**
@@ -213,14 +303,33 @@ const GUARDED_KEY_COLUMN = {
213
303
  Strand: 'Id',
214
304
  StrandPartyKey: 'Id',
215
305
  JoinedStrand: 'Id',
216
- PendingJoin: 'Id',
306
+ JoinRequest: 'Id',
217
307
  DeviceToken: 'PeerId',
308
+ CadreInvite: 'Key',
218
309
  };
219
- function signGuardedRemoval(tableName, rowKey, stampId, signMessage) {
310
+ /**
311
+ * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
312
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
313
+ * tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
314
+ * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
315
+ * `revocationDigest`.
316
+ */
317
+ function revocationTombstoneMessage(partyId, ref) {
318
+ return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
319
+ }
320
+ /**
321
+ * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
322
+ * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
323
+ */
324
+ function ownerKeyAddMessage(partyId, key, stampId) {
325
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
326
+ }
327
+ function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
328
+ const ref = { tableName, rowKey, stampId };
220
329
  return {
221
- ref: { tableName, rowKey, stampId },
222
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
223
- revocationSignature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId])),
330
+ ref,
331
+ signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
332
+ revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
224
333
  };
225
334
  }
226
335
  /**
@@ -254,8 +363,8 @@ export function isStrandIdConflict(error, table) {
254
363
  return errorChainMatches(error, idConflictPattern(table));
255
364
  }
256
365
  /**
257
- * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
258
- * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
366
+ * Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
367
+ * `JoinRequest` row for this invitation? The caller adopts the existing request instead.
259
368
  * Text-matched, failing closed, like {@link isStrandIdConflict}.
260
369
  *
261
370
  * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
@@ -263,10 +372,10 @@ export function isStrandIdConflict(error, table) {
263
372
  * until the next reap pass.
264
373
  */
265
374
  export function isPendingJoinConflict(error) {
266
- return errorChainMatches(error, idConflictPattern('PendingJoin'));
375
+ return errorChainMatches(error, idConflictPattern('JoinRequest'));
267
376
  }
268
377
  /**
269
- * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
378
+ * The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
270
379
  * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
271
380
  * the bearer token itself.
272
381
  */
@@ -274,17 +383,15 @@ export function pendingJoinId(token) {
274
383
  return digest([token], 'sha256', 'base64url');
275
384
  }
276
385
  /**
277
- * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
278
- * incarnation the caller read: another write replaced or removed it first (a sibling machine
279
- * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
280
- * re-reads and decides again. `liveStampId` is null when the row is gone.
386
+ * A pending-join write found that the live join is not the one the caller read: another write
387
+ * recorded an outcome, asked again or dismissed it first. Nothing was written; the caller
388
+ * decides again from `live`, which is null when the request is gone.
281
389
  */
282
390
  export class PendingJoinChangedError extends Error {
283
- constructor(id, expectedStampId, liveStampId) {
284
- super(`PendingJoin ${id} changed: expected stamp ${expectedStampId}, found ${liveStampId ?? 'no row'}`);
391
+ constructor(id, live) {
392
+ super(`Pending join ${id} changed: ${live === null ? 'no request' : `request ${live.StampId}, ${live.outcome?.kind ?? 'pending'}`}`);
285
393
  this.id = id;
286
- this.expectedStampId = expectedStampId;
287
- this.liveStampId = liveStampId;
394
+ this.live = live;
288
395
  this.name = 'PendingJoinChangedError';
289
396
  }
290
397
  }
@@ -315,6 +422,49 @@ function isRevocationLedgerConflict(error) {
315
422
  function errorChainMatches(error, pattern) {
316
423
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
317
424
  }
425
+ /**
426
+ * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
427
+ * redemption reply, carries.
428
+ *
429
+ * Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
430
+ * Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
431
+ * key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
432
+ * `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
433
+ * or unparsable id yields a non-owner rather than failing the whole projection.
434
+ *
435
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
436
+ * the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
437
+ * peers first — not a trust decision, and the receiver re-derives real trust from its own
438
+ * anchor. So a polluted table costs at most a wasted dial, while anchoring here would
439
+ * silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
440
+ * anything the receiver TRUSTS, move it to the anchor.
441
+ */
442
+ export function projectSeedPeers(rows, ownerKeys) {
443
+ return rows.map(({ peerId, multiaddr }) => {
444
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
445
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
446
+ return {
447
+ peerId,
448
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
449
+ isOwner,
450
+ ...(isOwner ? { publicKey: pubKeyB64 } : {}),
451
+ };
452
+ });
453
+ }
454
+ /**
455
+ * The conditions a cadre invitation must still meet to be redeemed, short of its use count:
456
+ * not withdrawn, unexpired (`expiresAt <= now` is expired, as the redemption refuses it),
457
+ * and issued by a current owner. Shared by {@link ControlDatabase.hasLiveCadreInvite},
458
+ * {@link ControlDatabase.isCadreInviteLive} and {@link ControlDatabase.listCadreInviteStatuses}
459
+ * so they cannot drift from each other, or from `CadreInviteUsage.Authorized`.
460
+ */
461
+ function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
462
+ if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
463
+ return false;
464
+ }
465
+ const expiresAtMs = parseNullableStoredDatetimeMs(row.expiresAt);
466
+ return expiresAtMs === null || expiresAtMs > nowMs;
467
+ }
318
468
  /**
319
469
  * Guarded tables a node may reap locally once their tombstone has committed — the
320
470
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
@@ -323,21 +473,54 @@ function errorChainMatches(error, pattern) {
323
473
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
324
474
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
325
475
  * secret is the joined strand's shared read key, which its founding party and every member
326
- * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
476
+ * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
327
477
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
328
478
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
329
- * party-bricking hazard.
479
+ * party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
480
+ * withdrawal over a row that stays), so there is nothing to reap.
330
481
  */
331
- export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'PendingJoin'];
482
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
332
483
  /**
333
484
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
334
485
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
335
- * naming an excluded table (`Strand`, `OwnerKey`) is skipped silently — `Strand`
336
- * tombstones are the common case in a party that has unpublished a strand, so a log line
337
- * per skip would be per-pass noise.
486
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
487
+ * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
488
+ * log line per skip would be per-pass noise.
338
489
  */
339
490
  const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
340
491
  const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
492
+ /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
493
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
494
+ /**
495
+ * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
496
+ * engine-canonical string it reads back as — the form the `'add'` signature covers — never
497
+ * parsed to ms here; `GrantsOwner` is the schema's 0/1 integer.
498
+ */
499
+ function cadreInviteRowOf(row) {
500
+ return {
501
+ key: row.Key,
502
+ peerId: row.PeerId ?? null,
503
+ grantsOwner: Number(row.GrantsOwner) === 1,
504
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
505
+ totalUses: row.TotalUses ?? null,
506
+ issuerKey: row.IssuerKey,
507
+ issuerSig: row.IssuerSig,
508
+ stampId: row.StampId,
509
+ };
510
+ }
511
+ /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
512
+ function cadreInviteUsageRowOf(row) {
513
+ return {
514
+ usageStampId: row.UsageStampId,
515
+ inviteKey: row.InviteKey,
516
+ peerId: row.PeerId,
517
+ peerKey: row.PeerKey,
518
+ peerStampId: row.PeerStampId,
519
+ ownerStampId: row.OwnerStampId ?? null,
520
+ inviteSig: row.InviteSig,
521
+ peerSig: row.PeerSig,
522
+ };
523
+ }
341
524
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
342
525
  function joinedStrandRow(row) {
343
526
  return {
@@ -347,47 +530,90 @@ function joinedStrandRow(row) {
347
530
  FounderOwnerKey: null,
348
531
  };
349
532
  }
350
- /** Every `PendingJoin` column, in the schema's order. */
351
- const PENDING_JOIN_COLUMNS = 'Id, Invitation, Disclosure, RequestedAt, ExpiresAt, Outcome, OutcomeAt, StrandId, MembershipInvite, FailureCode, FailureReason, StampId';
352
- const PENDING_JOIN_INSERT_SQL = `
353
- insert into CadreControl.PendingJoin (${PENDING_JOIN_COLUMNS})
354
- with context OwnerKey = ?, Signature = ?
355
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
533
+ const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
534
+ function formationInviteRowOf(row) {
535
+ return {
536
+ token: row.Token,
537
+ sAppId: row.sAppId,
538
+ expiresAtMs: parseNullableStoredDatetimeMs(row.ExpiresAt),
539
+ totalUses: row.TotalUses ?? null,
540
+ validationUrl: row.ValidationUrl ?? null,
541
+ strandId: row.StrandId ?? null,
542
+ };
543
+ }
544
+ const JOIN_REQUEST_INSERT_SQL = `
545
+ insert into CadreControl.JoinRequest (Id, Invitation, Disclosure, RequestedAt, ExpiresAt, StampId)
546
+ with context OwnerKey = ?, Signature = ?
547
+ values (?, ?, ?, ?, ?, ?)
548
+ `;
549
+ const JOIN_SUCCESS_INSERT_SQL = `
550
+ insert into CadreControl.JoinSuccess (RequestStampId, RecordedAt, StrandId, MembershipInvite)
551
+ with context OwnerKey = ?, Signature = ?
552
+ values (?, ?, ?, ?)
553
+ `;
554
+ const JOIN_FAILURE_INSERT_SQL = `
555
+ insert into CadreControl.JoinFailure (RequestStampId, RecordedAt, Code, Reason)
556
+ with context OwnerKey = ?, Signature = ?
557
+ values (?, ?, ?, ?)
558
+ `;
559
+ /**
560
+ * A request with its outcome in one statement: the outcome tables are keyed by the request's
561
+ * stamp, so the join finds exactly this incarnation's outcome.
562
+ */
563
+ const PENDING_JOIN_SELECT_SQL = `
564
+ select R.Id, R.Invitation, R.Disclosure, R.RequestedAt, R.ExpiresAt, R.StampId,
565
+ S.RecordedAt as JoinedAt, S.StrandId, S.MembershipInvite,
566
+ F.RecordedAt as FailedAt, F.Code, F.Reason
567
+ from CadreControl.JoinRequest R
568
+ left join CadreControl.JoinSuccess S on S.RequestStampId = R.StampId
569
+ left join CadreControl.JoinFailure F on F.RequestStampId = R.StampId
356
570
  `;
357
- /** A `PendingJoin` row as read; the schema's `KnownOutcome` check backs the `Outcome` cast. */
358
- function pendingJoinRow(row) {
571
+ /** A {@link PENDING_JOIN_SELECT_SQL} row as read. */
572
+ function pendingJoinOf(row) {
359
573
  return {
360
574
  Id: row.Id,
361
575
  Invitation: row.Invitation,
362
576
  Disclosure: row.Disclosure,
363
577
  RequestedAt: row.RequestedAt,
364
578
  ExpiresAt: row.ExpiresAt,
365
- Outcome: row.Outcome,
366
- OutcomeAt: row.OutcomeAt,
367
- StrandId: row.StrandId,
368
- MembershipInvite: row.MembershipInvite,
369
- FailureCode: row.FailureCode,
370
- FailureReason: row.FailureReason,
371
579
  StampId: row.StampId,
580
+ outcome: joinOutcomeOf(row),
372
581
  };
373
582
  }
583
+ /** The outcome half of a {@link PENDING_JOIN_SELECT_SQL} row; a request never has both (`JoinSuccess.NoFailure`, `JoinFailure.NotJoined`). */
584
+ function joinOutcomeOf(row) {
585
+ if (typeof row.JoinedAt === 'number') {
586
+ return { kind: 'joined', RecordedAt: row.JoinedAt, StrandId: row.StrandId, MembershipInvite: row.MembershipInvite };
587
+ }
588
+ if (typeof row.FailedAt === 'number') {
589
+ return { kind: 'failed', RecordedAt: row.FailedAt, Code: row.Code, Reason: row.Reason };
590
+ }
591
+ return null;
592
+ }
593
+ /** The fields `JoinRequest.AuthorizedInsert` signs, in the schema's order; integers sign as their text. */
594
+ function joinRequestAddFields(row) {
595
+ return [row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt), row.StampId];
596
+ }
374
597
  /**
375
- * The row fields `PendingJoin.AuthorizedInsert` signs, in the schema's order: a null column
376
- * signs as `''` and an integer as its text (`cast(... as text)` on the SQL side).
598
+ * The owner-signed insert of `outcome` for the request incarnation `requestStampId`: the
599
+ * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
600
+ * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
377
601
  */
378
- function pendingJoinAddFields(row) {
379
- return [
380
- row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt),
381
- row.Outcome ?? '', row.OutcomeAt === null ? '' : String(row.OutcomeAt), row.StrandId ?? '',
382
- row.MembershipInvite ?? '', row.FailureCode ?? '', row.FailureReason ?? '', row.StampId,
383
- ];
384
- }
385
- /** The values of {@link PENDING_JOIN_INSERT_SQL}'s columns, in {@link PENDING_JOIN_COLUMNS} order. */
386
- function pendingJoinValues(row) {
387
- return [
388
- row.Id, row.Invitation, row.Disclosure, row.RequestedAt, row.ExpiresAt, row.Outcome, row.OutcomeAt,
389
- row.StrandId, row.MembershipInvite, row.FailureCode, row.FailureReason, row.StampId,
390
- ];
602
+ function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
603
+ const at = String(outcome.RecordedAt);
604
+ const { table, sql, fields, values } = outcome.kind === 'joined'
605
+ ? {
606
+ table: 'JoinSuccess', sql: JOIN_SUCCESS_INSERT_SQL,
607
+ fields: [requestStampId, at, outcome.StrandId, outcome.MembershipInvite ?? ''],
608
+ values: [requestStampId, outcome.RecordedAt, outcome.StrandId, outcome.MembershipInvite],
609
+ }
610
+ : {
611
+ table: 'JoinFailure', sql: JOIN_FAILURE_INSERT_SQL,
612
+ fields: [requestStampId, at, outcome.Code, outcome.Reason],
613
+ values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
614
+ };
615
+ const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
616
+ return { sql, params: [ownerKey, signature, ...values] };
391
617
  }
392
618
  /**
393
619
  * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
@@ -424,6 +650,9 @@ export class ControlDatabase {
424
650
  * backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
425
651
  */
426
652
  this.controlReadRetryPacing = {};
653
+ if (typeof config.partyId !== 'string' || config.partyId.length === 0) {
654
+ throw new Error('ControlDatabase requires a non-empty partyId: every control approval is bound to it');
655
+ }
427
656
  this.config = config;
428
657
  }
429
658
  /**
@@ -459,6 +688,8 @@ export class ControlDatabase {
459
688
  this.db.registerFunction(func.schema);
460
689
  }
461
690
  timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
691
+ // Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
692
+ registerPartyIdFunction(this.db, this.config.partyId);
462
693
  this.collectionFactory = pluginResult.collectionFactory;
463
694
  // Inject the libp2p node into the collection factory
464
695
  t0 = performance.now();
@@ -810,27 +1041,31 @@ export class ControlDatabase {
810
1041
  return rows.length === 0 ? null : joinedStrandRow(rows[0]);
811
1042
  }
812
1043
  /**
813
- * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
814
- * pending or finished.
1044
+ * Every join this party asked for and has not dismissed, pending or finished: each
1045
+ * `CadreControl.JoinRequest` row with its `JoinSuccess` / `JoinFailure` row.
815
1046
  *
816
- * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
817
- * drops them: such a row is either a replaced or dismissed incarnation this node still
818
- * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
819
- * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
1047
+ * A request whose `StampId` is retired in `Revocation` is dropped, as {@link queryCadrePeers}
1048
+ * drops such rows: it is a re-asked or dismissed incarnation this node still holds until the
1049
+ * reap sweep removes it, or one re-seated by a replayed approval on a node that had not yet
1050
+ * seen the tombstone. Either way it must not bring a dismissed join back.
820
1051
  */
821
1052
  async queryPendingJoins() {
822
1053
  this.ensureInitialized();
823
- const revoked = await this.queryRevokedStamps('PendingJoin');
824
- const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin`, undefined, 'pending-joins');
825
- return rows.map(pendingJoinRow).filter(row => !revoked.has(row.StampId));
1054
+ const revoked = await this.queryRevokedStamps('JoinRequest');
1055
+ const rows = await this.readRows(PENDING_JOIN_SELECT_SQL, undefined, 'pending-joins');
1056
+ return rows.map(pendingJoinOf).filter(join => !revoked.has(join.StampId));
826
1057
  }
827
- /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
1058
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the join with this id, or null. */
828
1059
  async queryPendingJoin(id) {
829
1060
  this.ensureInitialized();
830
- const revoked = await this.queryRevokedStamps('PendingJoin');
831
- const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin where Id = ?`, [id], 'pending-join');
832
- const row = rows.length === 0 ? null : pendingJoinRow(rows[0]);
833
- return row === null || revoked.has(row.StampId) ? null : row;
1061
+ const revoked = await this.queryRevokedStamps('JoinRequest');
1062
+ const join = await this.readPendingJoin(id, true);
1063
+ return join === null || revoked.has(join.StampId) ? null : join;
1064
+ }
1065
+ /** The raw read behind {@link queryPendingJoin}, with no retired-stamp filter. `retry: false` inside a locked write body. */
1066
+ async readPendingJoin(id, retry) {
1067
+ const rows = await this.readRows(`${PENDING_JOIN_SELECT_SQL} where R.Id = ?`, [id], 'pending-join', retry);
1068
+ return rows.length === 0 ? null : pendingJoinOf(rows[0]);
834
1069
  }
835
1070
  /**
836
1071
  * Count rows in a CadreControl table as seen by THIS database instance.
@@ -863,6 +1098,9 @@ export class ControlDatabase {
863
1098
  /**
864
1099
  * Check whether any owner key exists in the control database.
865
1100
  * Used to decide whether a fresh-party genesis insert is required.
1101
+ *
1102
+ * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1103
+ * insert is refused by the schema whenever any physical row exists, so this must see one.
866
1104
  */
867
1105
  async hasOwnerKey() {
868
1106
  this.ensureInitialized();
@@ -893,14 +1131,65 @@ export class ControlDatabase {
893
1131
  * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
894
1132
  * also the owner-identity source for `queryPeers`, decoupling owner
895
1133
  * status from the libp2p transport peer ID.
896
- */
897
- async getOwnerKeys() {
1134
+ *
1135
+ * Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded, as
1136
+ * {@link queryCadrePeers} excludes them: `OwnerKey` is never reaped (`MinOneOwner` makes
1137
+ * an automated reap able to empty the table), so a node that received the tombstone
1138
+ * without the delete (a removal committed while the remover was alone, re-issued on growth)
1139
+ * keeps the removed owner's physical row next to it. Without the filter a removed owner
1140
+ * would still count as an owner in every membership snapshot and seed-trust read, still
1141
+ * read as the issuer of a live invitation and still be preferred as a dial target. The
1142
+ * write side filters for itself: every owner lookup in the schema disqualifies a row whose
1143
+ * stamp is retired (`schemas/control.qsql` header), so a removed owner cannot sign here
1144
+ * once its tombstone has arrived, row or no row.
1145
+ *
1146
+ * `retry: false` from a caller that reads under the write lock, for the reason on
1147
+ * {@link queryCadrePeers}.
1148
+ */
1149
+ async getOwnerKeys(retry = true) {
1150
+ return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
1151
+ }
1152
+ /**
1153
+ * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1154
+ * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1155
+ * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1156
+ * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1157
+ * reads through this. A row with no stamp (the schema permits it; no writer produces one)
1158
+ * is dropped too: no proof can bind to it, so no reader could ever derive it.
1159
+ *
1160
+ * `retry: false` from the membership-gate refresh, which runs as the control database's
1161
+ * membership listener with that database's write lock held — the reason on
1162
+ * {@link queryCadrePeers}; both reads here take the flag.
1163
+ *
1164
+ * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1165
+ * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1166
+ * signers); omitted, they are read here. Before the ledger marker is filed every
1167
+ * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1168
+ * membership refresh at one such read for the anchor.
1169
+ */
1170
+ async queryOwnerKeyRows(retry = true, retired) {
898
1171
  this.ensureInitialized();
899
- const keys = new Set();
900
- for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
901
- keys.add(row.Key);
1172
+ const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1173
+ const rows = [];
1174
+ const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1175
+ for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1176
+ const stampId = row.StampId ?? null;
1177
+ if (stampId === null) {
1178
+ log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
1179
+ continue;
1180
+ }
1181
+ if (revoked.has(stampId)) {
1182
+ continue;
1183
+ }
1184
+ rows.push({
1185
+ key: row.Key,
1186
+ stampId,
1187
+ vouchOwner: row.VouchOwner ?? null,
1188
+ vouchSig: row.VouchSig ?? null,
1189
+ vouchUsage: row.VouchUsage ?? null,
1190
+ });
902
1191
  }
903
- return keys;
1192
+ return rows;
904
1193
  }
905
1194
  /**
906
1195
  * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
@@ -942,21 +1231,43 @@ export class ControlDatabase {
942
1231
  this.ensureInitialized();
943
1232
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
944
1233
  const rows = [];
945
- for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
1234
+ // The proof columns lead and the record columns follow, so this scan keeps a prefix no
1235
+ // other `CadrePeer` statement shares (`queryPeerRecord` starts `PeerId, PublicKey`): the
1236
+ // read-retry spec rigs the membership scan by that prefix.
1237
+ const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
1238
+ for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
946
1239
  const stampId = row.StampId ?? null;
947
1240
  if (stampId !== null && revoked.has(stampId)) {
948
1241
  continue;
949
1242
  }
950
1243
  rows.push({
951
1244
  peerId: row.PeerId,
1245
+ publicKey: row.PublicKey ?? null,
952
1246
  multiaddr: row.Multiaddr ?? null,
1247
+ updatedAt: row.UpdatedAt ?? 0,
1248
+ profile: row.Profile ?? null,
1249
+ sig: row.Sig ?? null,
953
1250
  stampId,
954
1251
  vouchOwner: row.VouchOwner ?? null,
955
1252
  vouchSig: row.VouchSig ?? null,
1253
+ vouchUsage: row.VouchUsage ?? null,
956
1254
  });
957
1255
  }
958
1256
  return rows;
959
1257
  }
1258
+ /**
1259
+ * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
1260
+ * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
1261
+ * the replicated `OwnerKey` table ({@link projectSeedPeers}).
1262
+ *
1263
+ * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1264
+ * member's addresses are never packed into a seed or handed to a redeeming device as a
1265
+ * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1266
+ * its exits.
1267
+ */
1268
+ async querySeedPeers() {
1269
+ return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
1270
+ }
960
1271
  /**
961
1272
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
962
1273
  * set a `ValidationUrl` redemption's approval signature must be signed by (see
@@ -1136,22 +1447,26 @@ export class ControlDatabase {
1136
1447
  }
1137
1448
  }
1138
1449
  /**
1139
- * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
1140
- * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
1141
- * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
1142
- * it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
1143
- * the composite-primary-key point-lookup hazard (see the statement comment in
1450
+ * Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
1451
+ * `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
1452
+ * sweep, which enumerates what this node holds before {@link reissueRevocations}
1453
+ * re-broadcasts it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
1454
+ * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1144
1455
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1145
1456
  *
1146
1457
  * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1147
1458
  * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1148
1459
  * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1149
1460
  * admits no other row under `'Revocation'`.
1461
+ *
1462
+ * `retry: false` from a caller that reads under the write lock, for the reason on
1463
+ * {@link queryCadrePeers}.
1150
1464
  */
1151
- async queryRevocations() {
1465
+ async queryRevocations(retry = true) {
1152
1466
  this.ensureInitialized();
1153
1467
  const rows = [];
1154
- for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
1468
+ const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
1469
+ for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
1155
1470
  if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1156
1471
  continue;
1157
1472
  }
@@ -1160,6 +1475,8 @@ export class ControlDatabase {
1160
1475
  rowKey: row.RowKey,
1161
1476
  stampId: row.StampId,
1162
1477
  reissuedAt: row.ReissuedAt ?? 0,
1478
+ signerKey: row.SignerKey,
1479
+ signerSig: row.SignerSig,
1163
1480
  });
1164
1481
  }
1165
1482
  return rows;
@@ -1185,7 +1502,7 @@ export class ControlDatabase {
1185
1502
  async queryPeerRecord(peerId) {
1186
1503
  this.ensureInitialized();
1187
1504
  const revoked = await this.queryRevokedStamps('CadrePeer');
1188
- for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
1505
+ for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Profile, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
1189
1506
  const stampId = row.StampId ?? null;
1190
1507
  if (stampId !== null && revoked.has(stampId)) {
1191
1508
  return null;
@@ -1196,6 +1513,7 @@ export class ControlDatabase {
1196
1513
  publicKey: row.PublicKey ?? '',
1197
1514
  addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
1198
1515
  updatedAt: row.UpdatedAt ?? 0,
1516
+ profile: row.Profile ?? null,
1199
1517
  sig: row.Sig ?? '',
1200
1518
  };
1201
1519
  }
@@ -1227,12 +1545,12 @@ export class ControlDatabase {
1227
1545
  async updateSelfPeerRecord(record) {
1228
1546
  this.ensureInitialized();
1229
1547
  const multiaddr = record.addrs.join(',');
1230
- await this.execWrite(`
1231
- update CadreControl.CadrePeer
1232
- with context OwnerKey = null, Signature = ?
1233
- set Multiaddr = ?, UpdatedAt = ?, Sig = ?
1234
- where PeerId = ?
1235
- `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
1548
+ await this.execWrite(`
1549
+ update CadreControl.CadrePeer
1550
+ with context OwnerKey = null, Signature = ?
1551
+ set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
1552
+ where PeerId = ?
1553
+ `, [record.sig, multiaddr, record.updatedAt, record.profile, record.sig, record.peerId], 'self-record-update');
1236
1554
  log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
1237
1555
  }
1238
1556
  /**
@@ -1275,11 +1593,11 @@ export class ControlDatabase {
1275
1593
  */
1276
1594
  async updateSelfDeviceToken(record) {
1277
1595
  this.ensureInitialized();
1278
- await this.execWrite(`
1279
- update CadreControl.DeviceToken
1280
- with context OwnerKey = null, Signature = ?
1281
- set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
1282
- where PeerId = ?
1596
+ await this.execWrite(`
1597
+ update CadreControl.DeviceToken
1598
+ with context OwnerKey = null, Signature = ?
1599
+ set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
1600
+ where PeerId = ?
1283
1601
  `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
1284
1602
  log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
1285
1603
  }
@@ -1291,20 +1609,241 @@ export class ControlDatabase {
1291
1609
  log('Inserting owner key: %s', key);
1292
1610
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
1293
1611
  // committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
1294
- // signature is needed. Every other branch of `OwnerKey.Authorized` requires a signature
1295
- // from a PRE-EXISTING owner, so this method only ever succeeds on a fresh party; seating
1296
- // a second owner (or removing one) has no writer here and must sign the digests
1297
- // documented on the schema's `Authorized` constraint. We still persist a fresh, unique
1298
- // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
1299
- // the StampId is a real column value, not the optimystic `StampId()` SQL function.
1612
+ // signature is needed. Every other branch of `OwnerKey.Authorized` requires a PRE-EXISTING,
1613
+ // unretired owner, so this method only ever succeeds on a fresh party; a second
1614
+ // owner is seated by {@link insertOwnerKeyVouched} and removed by {@link deleteOwnerKey}.
1615
+ // We still persist a fresh, unique StampId in the row's own column to satisfy the
1616
+ // not-null/unique anti-replay constraint — the StampId is a real column value, not the
1617
+ // optimystic `StampId()` SQL function.
1300
1618
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1301
- await this.execWrite(`
1302
- insert into CadreControl.OwnerKey (Key, StampId)
1303
- with context OwnerKey = null, Signature = null
1304
- values (?, ?)
1619
+ await this.execWrite(`
1620
+ insert into CadreControl.OwnerKey (Key, StampId)
1621
+ with context OwnerKey = null, Signature = null
1622
+ values (?, ?)
1305
1623
  `, [key, stampId], 'owner-key-insert');
1306
1624
  log('Owner key inserted');
1307
1625
  }
1626
+ /**
1627
+ * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
1628
+ * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
1629
+ * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
1630
+ * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
1631
+ * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
1632
+ *
1633
+ * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
1634
+ * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
1635
+ * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
1636
+ *
1637
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
1638
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
1639
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
1640
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
1641
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
1642
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
1643
+ *
1644
+ * @returns `true` when this call seated the row, `false` when `key` is already a live owner
1645
+ * here (nothing written).
1646
+ * @throws when this node holds `key`'s row under a retired stamp (above), or when the
1647
+ * schema refuses the signer.
1648
+ */
1649
+ async insertOwnerKeyVouched(key, ownerKey, signMessage) {
1650
+ this.ensureInitialized();
1651
+ return await this.mutateCadrePeer('owner-key-add', async () => {
1652
+ // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1653
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
1654
+ if (heldStamp !== null) {
1655
+ if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
1656
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
1657
+ return false;
1658
+ }
1659
+ throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
1660
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it');
1661
+ }
1662
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1663
+ const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
1664
+ // Bare `exec`: already inside the write lock, which is NOT re-entrant.
1665
+ await this.db.exec(`
1666
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1667
+ with context OwnerKey = ?, Signature = ?
1668
+ values (?, ?, ?, ?)
1669
+ `, [ownerKey, signature, key, stampId, ownerKey, signature]);
1670
+ log('Owner key %s added (vouched by %s)', key, ownerKey);
1671
+ return true;
1672
+ });
1673
+ }
1674
+ /**
1675
+ * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
1676
+ * stamp, notifying the membership listener once it has committed. The remove branch of
1677
+ * `OwnerKey.Authorized` requires a signer other than the removed key, and `MinOneOwner`
1678
+ * keeps the table from emptying; both are refused by name here first, read from this
1679
+ * node's live owners ({@link getOwnerKeys}), so a caller gets a clear error rather than a
1680
+ * constraint failure. The schema stays the authority: a concurrent removal elsewhere can
1681
+ * make the local count wrong in either direction, and then `MinOneOwner` or the tombstone's
1682
+ * primary key decides (see the NOTE on {@link deleteGuardedRow}).
1683
+ *
1684
+ * Through {@link mutateCadrePeer}, the membership hub, which notifies whenever the body
1685
+ * resolves, the absent-key answer included.
1686
+ *
1687
+ * @returns `true` when the row was removed, `false` when `key` is not a live owner here
1688
+ * (nothing written, no tombstone).
1689
+ * @throws when `key` is `ownerKey`, or is the only live owner.
1690
+ */
1691
+ async deleteOwnerKey(key, ownerKey, signMessage) {
1692
+ if (key === ownerKey) {
1693
+ throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
1694
+ }
1695
+ return await this.mutateCadrePeer('owner-key-remove', async () => {
1696
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
1697
+ const owners = await this.getOwnerKeys(false);
1698
+ if (!owners.has(key)) {
1699
+ log('deleteOwnerKey: %s is not a live owner here (already absent)', key);
1700
+ return false;
1701
+ }
1702
+ if (owners.size <= 1) {
1703
+ throw new Error(`Cannot remove owner key ${key}: cannot remove the last owner`);
1704
+ }
1705
+ return await this.deleteGuardedRow('OwnerKey', key, ownerKey, signMessage);
1706
+ });
1707
+ }
1708
+ /**
1709
+ * Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
1710
+ * current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
1711
+ * of the same key under a fresh stamp, signed by `ownerKey` ({@link insertOwnerKeyVouched}'s
1712
+ * body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
1713
+ * this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
1714
+ * under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
1715
+ * {@link removeMachine}: the owners a machine being removed had added keep their anchor
1716
+ * place everywhere once their row stands on a remaining owner's signature.
1717
+ *
1718
+ * Through {@link mutateCadrePeer}, the membership hub. The stamp read runs inside the locked
1719
+ * body; a row whose stamp is already retired (held without its delete) is reported as absent
1720
+ * rather than tombstoned twice.
1721
+ *
1722
+ * @returns `true` when the row was re-vouched, `false` when `key` is not a live owner here.
1723
+ * @throws when `key` is `ownerKey` (an owner cannot sign its own row's removal).
1724
+ */
1725
+ async revouchOwnerKey(key, ownerKey, signMessage) {
1726
+ if (key === ownerKey) {
1727
+ throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
1728
+ }
1729
+ return await this.mutateCadrePeer('owner-key-revouch', async () => {
1730
+ // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1731
+ const stampId = await this.queryStampId('OwnerKey', key, false);
1732
+ if (stampId === null || (await this.queryRevokedStamps('OwnerKey', false)).has(stampId)) {
1733
+ log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
1734
+ return false;
1735
+ }
1736
+ const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
1737
+ const freshStampId = generateStampId(this.config.libp2pNode.peerId.toString());
1738
+ const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, freshStampId));
1739
+ await this.inTransaction('revouch OwnerKey', async () => {
1740
+ await this.execGuardedRemoval(removal, ownerKey);
1741
+ await this.db.exec(`
1742
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1743
+ with context OwnerKey = ?, Signature = ?
1744
+ values (?, ?, ?, ?)
1745
+ `, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
1746
+ });
1747
+ log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
1748
+ this.notifyGuardedDelete(removal.ref);
1749
+ return true;
1750
+ });
1751
+ }
1752
+ /**
1753
+ * Remove one machine completely, in ONE transaction: its `CadrePeer` row, its `OwnerKey`
1754
+ * row when `target.ownerKey` names one, its `DeviceToken` row when it has one, each with
1755
+ * the `Revocation` tombstone retiring its stamp ({@link deleteGuardedRow}'s statement pair,
1756
+ * composed bare as {@link deleteStrandAndPartyKey} composes it), plus a withdrawal tombstone
1757
+ * for every `CadreInvite` in `target.inviteKeys` that is still live here
1758
+ * ({@link withdrawCadreInvite}'s body). One transaction so no reader sees the machine half
1759
+ * removed: a row gone but its push token resolvable, or its owner key gone but an invitation
1760
+ * it could redeem again still open.
1761
+ *
1762
+ * Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
1763
+ * body, and a row whose stamp is already retired (held without its delete) is skipped rather
1764
+ * than tombstoned twice. The by-name owner refusals of {@link deleteOwnerKey} apply to the
1765
+ * owner-key arm. Nothing to remove writes nothing and returns all false.
1766
+ *
1767
+ * The caller decides WHAT to remove, which owner key is the machine's and which invitations
1768
+ * it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
1769
+ * write an inconsistent set.
1770
+ *
1771
+ * @throws when `target.ownerKey` is `ownerKey`, or is the only live owner here.
1772
+ */
1773
+ async removeMachine(target, ownerKey, signMessage) {
1774
+ if (target.ownerKey === ownerKey) {
1775
+ throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
1776
+ }
1777
+ return await this.mutateCadrePeer('machine-remove', async () => {
1778
+ const removals = await this.signMachineRemovals(target, signMessage);
1779
+ const withdrawals = await this.signCadreInviteWithdrawals(target.inviteKeys, signMessage);
1780
+ if (removals.length === 0 && withdrawals.length === 0) {
1781
+ log('removeMachine: nothing live names %s here (already removed)', target.peerId);
1782
+ return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [] };
1783
+ }
1784
+ await this.inTransaction('remove machine', async () => {
1785
+ for (const removal of removals) {
1786
+ await this.execGuardedRemoval(removal, ownerKey);
1787
+ }
1788
+ for (const withdrawal of withdrawals) {
1789
+ await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
1790
+ }
1791
+ });
1792
+ const removed = new Set(removals.map((removal) => removal.ref.tableName));
1793
+ log('Machine %s removed: %s; %d invitation(s) withdrawn', target.peerId, [...removed].join(', ') || 'no row', withdrawals.length);
1794
+ for (const removal of removals) {
1795
+ this.notifyGuardedDelete(removal.ref);
1796
+ }
1797
+ for (const withdrawal of withdrawals) {
1798
+ this.notifyGuardedDelete(withdrawal.ref);
1799
+ }
1800
+ return {
1801
+ removedPeer: removed.has('CadrePeer'),
1802
+ removedOwnerKey: removed.has('OwnerKey'),
1803
+ removedDeviceToken: removed.has('DeviceToken'),
1804
+ withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
1805
+ };
1806
+ });
1807
+ }
1808
+ /**
1809
+ * The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
1810
+ * the `CadrePeer` row, the `OwnerKey` row when named, the `DeviceToken` row when present.
1811
+ * Inside the locked body (every read `retry: false`); one `Revocation` scan serves the
1812
+ * retired-stamp check for all three tables and the last-owner refusal.
1813
+ */
1814
+ async signMachineRemovals(target, signMessage) {
1815
+ const retiredByTable = new Map();
1816
+ for (const tombstone of await this.queryRevocations(false)) {
1817
+ const stamps = retiredByTable.get(tombstone.tableName) ?? new Set();
1818
+ stamps.add(tombstone.stampId);
1819
+ retiredByTable.set(tombstone.tableName, stamps);
1820
+ }
1821
+ const retired = (table) => retiredByTable.get(table) ?? new Set();
1822
+ const liveStamp = async (table, keyValue) => {
1823
+ const stampId = await this.queryStampId(table, keyValue, false);
1824
+ return stampId !== null && !retired(table).has(stampId) ? stampId : null;
1825
+ };
1826
+ const removals = [];
1827
+ const peerStamp = await liveStamp('CadrePeer', target.peerId);
1828
+ if (peerStamp !== null) {
1829
+ removals.push(signGuardedRemoval(this.config.partyId, 'CadrePeer', target.peerId, peerStamp, signMessage));
1830
+ }
1831
+ if (target.ownerKey !== null) {
1832
+ const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
1833
+ if (ownerStamp !== null) {
1834
+ const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
1835
+ if (owners.length <= 1) {
1836
+ throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
1837
+ }
1838
+ removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
1839
+ }
1840
+ }
1841
+ const tokenStamp = await liveStamp('DeviceToken', target.peerId);
1842
+ if (tokenStamp !== null) {
1843
+ removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
1844
+ }
1845
+ return removals;
1846
+ }
1308
1847
  /**
1309
1848
  * Insert a strand into the control database using an owner signature.
1310
1849
  *
@@ -1334,14 +1873,14 @@ export class ControlDatabase {
1334
1873
  // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
1335
1874
  // deliberately NOT in the digest — the schema binds it by equality to the
1336
1875
  // verified context.OwnerKey instead (see the constraint's comment).
1337
- const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
1876
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
1338
1877
  const signature = signMessage(message);
1339
1878
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
1340
1879
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
1341
- await this.execWrite(`
1342
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
1343
- with context OwnerKey = ?, Signature = ?
1344
- values (?, ?, ?, ?, ?)
1880
+ await this.execWrite(`
1881
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
1882
+ with context OwnerKey = ?, Signature = ?
1883
+ values (?, ?, ?, ?, ?)
1345
1884
  `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
1346
1885
  log('Strand inserted: %s', strandId);
1347
1886
  }
@@ -1396,10 +1935,10 @@ export class ControlDatabase {
1396
1935
  return false;
1397
1936
  }
1398
1937
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1399
- const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
1938
+ const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
1400
1939
  const partyKeyRemoval = partyKeyStamp === null
1401
1940
  ? null
1402
- : signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
1941
+ : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
1403
1942
  await this.inTransaction('delete Strand', async () => {
1404
1943
  await this.execGuardedRemoval(strandRemoval, ownerKey);
1405
1944
  if (partyKeyRemoval !== null) {
@@ -1436,12 +1975,12 @@ export class ControlDatabase {
1436
1975
  const stampId = generateStampId(peerId);
1437
1976
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
1438
1977
  // Id, PrivateKey, StampId.
1439
- const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
1978
+ const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
1440
1979
  const signature = signMessage(message);
1441
- await this.execWrite(`
1442
- insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
1443
- with context OwnerKey = ?, Signature = ?
1444
- values (?, ?, ?)
1980
+ await this.execWrite(`
1981
+ insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
1982
+ with context OwnerKey = ?, Signature = ?
1983
+ values (?, ?, ?)
1445
1984
  `, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
1446
1985
  log('Strand party key inserted for strand: %s', strandId);
1447
1986
  }
@@ -1478,12 +2017,12 @@ export class ControlDatabase {
1478
2017
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1479
2018
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
1480
2019
  // Id, Type, MemberPrivateKey ('' when null), StampId.
1481
- const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
2020
+ const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
1482
2021
  const signature = signMessage(message);
1483
- await this.execWrite(`
1484
- insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
1485
- with context OwnerKey = ?, Signature = ?
1486
- values (?, ?, ?, ?)
2022
+ await this.execWrite(`
2023
+ insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
2024
+ with context OwnerKey = ?, Signature = ?
2025
+ values (?, ?, ?, ?)
1487
2026
  `, [ownerKey, signature, row.Id, row.Type, row.MemberPrivateKey, stampId], 'joined-strand-insert');
1488
2027
  log('Joined strand inserted: %s', row.Id);
1489
2028
  }
@@ -1497,76 +2036,124 @@ export class ControlDatabase {
1497
2036
  return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
1498
2037
  }
1499
2038
  /**
1500
- * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
1501
- * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
1502
- * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
2039
+ * Record, party-wide, a join this party asked for: a `JoinRequest` row under a fresh stamp,
2040
+ * owner-signed over every column. Returns the pending join as written.
1503
2041
  *
1504
- * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
1505
- * this invitation; {@link isPendingJoinConflict} identifies that case.
2042
+ * Fails with a `JoinRequest.Id` uniqueness violation when the party already holds a request
2043
+ * for this invitation; {@link isPendingJoinConflict} identifies that case.
1506
2044
  */
1507
- async insertPendingJoin(row, ownerKey, signMessage) {
2045
+ async insertJoinRequest(request, ownerKey, signMessage) {
1508
2046
  this.ensureInitialized();
1509
- log('Inserting pending join: %s (outcome: %s)', row.Id, row.Outcome);
1510
- const { written, params } = this.signPendingJoinInsert(row, ownerKey, signMessage);
1511
- await this.execWrite(PENDING_JOIN_INSERT_SQL, params, 'pending-join-insert');
1512
- log('Pending join inserted: %s', row.Id);
1513
- return written;
2047
+ const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2048
+ await this.execWrite(insert.sql, insert.params, 'join-request-insert');
2049
+ log('Join request inserted: %s', request.Id);
2050
+ return { ...written, outcome: null };
1514
2051
  }
1515
2052
  /**
1516
- * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
1517
- * an outcome is written, since the table refuses updates. Returns the row as written.
2053
+ * Record the outcome of the pending join `expected`: its owner-signed `JoinSuccess` /
2054
+ * `JoinFailure` row, keyed by the request's stamp. A join recorded over a failure deletes the
2055
+ * failure row in the same transaction (the schema authorizes that delete by the join, and
2056
+ * leaves the one-outcome rule in that direction to this writer). Returns the pending join as
2057
+ * written.
1518
2058
  *
1519
- * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
1520
- * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
1521
- * between. The live row must still be the incarnation the caller read: when its `StampId` is
1522
- * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
1523
- * writes nothing. That check is local; a sibling machine's replacement or removal that lands
1524
- * between it and the commit fails the transaction instead (its tombstone for the same stamp,
1525
- * or its successor row, collides on a primary key), and the caller re-reads either way.
2059
+ * The live join must still be what the caller read: the same request incarnation with the
2060
+ * same outcome kind. Otherwise this throws {@link PendingJoinChangedError} and writes nothing.
2061
+ * That check is local; a sibling machine's write landing between it and the commit fails the
2062
+ * transaction instead (its outcome row collides on the primary key, or its tombstone retires
2063
+ * the stamp), and the caller re-reads either way.
2064
+ */
2065
+ async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
2066
+ this.ensureInitialized();
2067
+ const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2068
+ return this.lockedWithRetry(async () => {
2069
+ const live = await this.readPendingJoin(expected.Id, false);
2070
+ if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
2071
+ throw new PendingJoinChangedError(expected.Id, live);
2072
+ }
2073
+ await this.inTransaction('record join outcome', async () => {
2074
+ if (live.outcome?.kind === 'failed') {
2075
+ await this.deleteJoinOutcome('JoinFailure', expected.StampId);
2076
+ }
2077
+ await this.db.exec(insert.sql, insert.params);
2078
+ });
2079
+ log('Join outcome recorded: %s (%s)', expected.Id, outcome.kind);
2080
+ return { ...live, outcome };
2081
+ }, {}, 'join-outcome-record');
2082
+ }
2083
+ /**
2084
+ * Re-write the pending join `next` (request and outcome) under a fresh stamp, retiring the
2085
+ * live request incarnation `expectedStampId`: how a finished request is asked for again, and
2086
+ * how a join written while this machine was alone is re-broadcast. One locked transaction
2087
+ * deletes the outcome row and the request (owner-signed `'remove'` digest, with its
2088
+ * `Revocation` tombstone) and inserts the successors, so no reader ever sees the id missing in
2089
+ * between. Throws {@link PendingJoinChangedError} and writes nothing when the live request is
2090
+ * not `expectedStampId`.
1526
2091
  *
1527
2092
  * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
1528
2093
  * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
1529
- * that committed but reported failure throws {@link PendingJoinChangedError} whose
1530
- * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
2094
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose live join
2095
+ * carries the returned stamp, so a caller that re-reads finds its write.
1531
2096
  */
1532
- async replacePendingJoin(expectedStampId, next, ownerKey, signMessage) {
2097
+ async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
1533
2098
  this.ensureInitialized();
1534
- const id = next.Id;
1535
- const removal = signGuardedRemoval('PendingJoin', id, expectedStampId, signMessage);
1536
- const { written, params } = this.signPendingJoinInsert(next, ownerKey, signMessage);
2099
+ const { outcome, ...request } = next;
2100
+ const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2101
+ const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2102
+ const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
1537
2103
  return this.lockedWithRetry(async () => {
1538
2104
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1539
- const liveStampId = await this.queryStampId('PendingJoin', id, false);
2105
+ const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
1540
2106
  if (liveStampId !== expectedStampId) {
1541
- throw new PendingJoinChangedError(id, expectedStampId, liveStampId);
2107
+ throw new PendingJoinChangedError(next.Id, await this.readPendingJoin(next.Id, false));
1542
2108
  }
1543
- await this.inTransaction('replace PendingJoin', async () => {
2109
+ await this.inTransaction('rewrite pending join', async () => {
2110
+ await this.deleteJoinOutcomes(expectedStampId);
1544
2111
  await this.execGuardedRemoval(removal, ownerKey);
1545
- await this.db.exec(PENDING_JOIN_INSERT_SQL, params);
2112
+ await this.db.exec(insert.sql, insert.params);
2113
+ if (outcomeInsert !== null) {
2114
+ await this.db.exec(outcomeInsert.sql, outcomeInsert.params);
2115
+ }
1546
2116
  });
1547
- log('Pending join replaced: %s (outcome: %s; stamp retired)', id, next.Outcome);
2117
+ log('Pending join rewritten: %s (%s; stamp retired)', next.Id, outcome?.kind ?? 'pending');
1548
2118
  this.notifyGuardedDelete(removal.ref);
1549
- return written;
1550
- }, {}, 'pending-join-replace');
2119
+ return { ...written, outcome };
2120
+ }, {}, 'pending-join-rewrite');
1551
2121
  }
1552
2122
  /**
1553
- * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
1554
- * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
1555
- * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
1556
- * `true` when a row was actually removed.
2123
+ * Owner-signed removal of one pending join, as {@link deleteJoinedStrand} removes its row:
2124
+ * `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same transaction,
2125
+ * and the request's outcome row with it. A no-op (no throw, no tombstone) when there is no
2126
+ * request — `false` then, `true` when one was actually removed.
1557
2127
  */
1558
2128
  deletePendingJoin(id, ownerKey, signMessage) {
1559
- return this.lockedWithRetry(() => this.deleteGuardedRow('PendingJoin', id, ownerKey, signMessage), {}, 'pending-join-delete');
2129
+ return this.lockedWithRetry(() => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)), {}, 'pending-join-delete');
1560
2130
  }
1561
2131
  /**
1562
- * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
1563
- * verifies, returning the row as it will be written and the parameters of
1564
- * {@link PENDING_JOIN_INSERT_SQL}.
2132
+ * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
2133
+ * verifies, returning the row as it will be written and its insert.
1565
2134
  */
1566
- signPendingJoinInsert(row, ownerKey, signMessage) {
1567
- const written = { ...row, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
1568
- const signature = signMessage(buildAuthorizationMessage('CadreControl.PendingJoin', 'add', pendingJoinAddFields(written)));
1569
- return { written, params: [ownerKey, signature, ...pendingJoinValues(written)] };
2135
+ signJoinRequestInsert(request, ownerKey, signMessage) {
2136
+ const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
2137
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2138
+ const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
2139
+ return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
2140
+ }
2141
+ /**
2142
+ * Remove the outcome row of the request incarnation `requestStampId`, whichever table holds
2143
+ * it. Unsigned: the schema authorizes the delete by the tombstone retiring that stamp, filed
2144
+ * in the same transaction or already committed (a reap).
2145
+ */
2146
+ async deleteJoinOutcomes(requestStampId) {
2147
+ await this.deleteJoinOutcome('JoinSuccess', requestStampId);
2148
+ await this.deleteJoinOutcome('JoinFailure', requestStampId);
2149
+ }
2150
+ /** One unsigned outcome delete (see {@link deleteJoinOutcomes}; a failure's is also authorized by the join superseding it). */
2151
+ async deleteJoinOutcome(table, requestStampId) {
2152
+ await this.db.exec(`
2153
+ delete from CadreControl.${table}
2154
+ with context OwnerKey = null, Signature = null
2155
+ where RequestStampId = ?
2156
+ `, [requestStampId]);
1570
2157
  }
1571
2158
  /**
1572
2159
  * Insert a validation key into the control database using an owner signature.
@@ -1587,12 +2174,12 @@ export class ControlDatabase {
1587
2174
  const peerId = this.config.libp2pNode.peerId.toString();
1588
2175
  const stampId = generateStampId(peerId);
1589
2176
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
1590
- const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
2177
+ const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
1591
2178
  const signature = signMessage(message);
1592
- await this.execWrite(`
1593
- insert into CadreControl.ValidationKey (Key, StampId)
1594
- with context OwnerKey = ?, Signature = ?
1595
- values (?, ?)
2179
+ await this.execWrite(`
2180
+ insert into CadreControl.ValidationKey (Key, StampId)
2181
+ with context OwnerKey = ?, Signature = ?
2182
+ values (?, ?)
1596
2183
  `, [ownerKey, signature, key, stampId], 'validation-key-insert');
1597
2184
  log('Validation key inserted: %s', key);
1598
2185
  }
@@ -1657,7 +2244,7 @@ export class ControlDatabase {
1657
2244
  // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
1658
2245
  // row it vouches.
1659
2246
  const stampId = generateStampId(row.peerId);
1660
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
2247
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
1661
2248
  return await this.mutateCadrePeer('peer-insert', async () => {
1662
2249
  // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
1663
2250
  if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
@@ -1670,11 +2257,11 @@ export class ControlDatabase {
1670
2257
  // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
1671
2258
  // identical to the context pair, which the AuthorizedInsert constraint binds — so a
1672
2259
  // reader can later re-check the voucher against its node-local trusted-owner anchor.
1673
- await this.db.exec(`
1674
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
1675
- with context OwnerKey = ?, Signature = ?
1676
- values (?, ?, ?, ?, ?, ?, ?, ?)
1677
- `, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
2260
+ await this.db.exec(`
2261
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig)
2262
+ with context OwnerKey = ?, Signature = ?
2263
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)
2264
+ `, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, row.profile, stampId, ownerKey, signature]);
1678
2265
  return true;
1679
2266
  });
1680
2267
  }
@@ -1688,7 +2275,8 @@ export class ControlDatabase {
1688
2275
  * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
1689
2276
  * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
1690
2277
  * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
1691
- * and voucher change — `PublicKey` / `Multiaddr` / `Sig` are left intact — so it is safe
2278
+ * and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
2279
+ * owner branch requires them kept: they are the machine's own claim) — so it is safe
1692
2280
  * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
1693
2281
  * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
1694
2282
  * `UpdatedAt` without re-signing would invalidate its self-signature).
@@ -1710,6 +2298,11 @@ export class ControlDatabase {
1710
2298
  * change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
1711
2299
  * beats a per-method exception the next reader has to relearn.
1712
2300
  *
2301
+ * Also clears `VouchUsage`: a row carries exactly one proof (the owner branch of
2302
+ * `AuthorizedUpdate` requires it null), so re-vouching an invitation-admitted row moves it
2303
+ * onto the signature and its invitation is no longer consulted for it — the way an owner
2304
+ * retires a device's dependency on an invitation it may later withdraw.
2305
+ *
1713
2306
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
1714
2307
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
1715
2308
  * reader's node-local anchor. Benign today because the only caller — the write-while-alone
@@ -1733,14 +2326,14 @@ export class ControlDatabase {
1733
2326
  log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
1734
2327
  return false;
1735
2328
  }
1736
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
2329
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
1737
2330
  await this.mutateCadrePeer('peer-reauthorize', async () => {
1738
2331
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1739
- await this.db.exec(`
1740
- update CadreControl.CadrePeer
1741
- with context OwnerKey = ?, Signature = ?
1742
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1743
- where PeerId = ?
2332
+ await this.db.exec(`
2333
+ update CadreControl.CadrePeer
2334
+ with context OwnerKey = ?, Signature = ?
2335
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2336
+ where PeerId = ?
1744
2337
  `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
1745
2338
  });
1746
2339
  return true;
@@ -1788,15 +2381,9 @@ export class ControlDatabase {
1788
2381
  * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
1789
2382
  * stamp, in ONE transaction. The single body behind EVERY guarded delete —
1790
2383
  * {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
1791
- * {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
1792
- * of those is a thin named wrapper, so callers never pass a table name and this
1793
- * generic shape stays off the public surface.
1794
- *
1795
- * `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
1796
- * production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
1797
- * requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
1798
- * it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
1799
- * needs no change.
2384
+ * {@link deleteDeviceToken}, {@link deleteOwnerKey}; see any of them for the per-table
2385
+ * security rationale. Each of those is a thin named wrapper, so callers never pass a
2386
+ * table name and this generic shape stays off the public surface.
1800
2387
  *
1801
2388
  * The row's CURRENT stamp is read first and signed over, so the remove digest binds to
1802
2389
  * this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
@@ -1804,6 +2391,9 @@ export class ControlDatabase {
1804
2391
  * second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
1805
2392
  * which must not fire for a no-op).
1806
2393
  *
2394
+ * `alongside`, when given, runs inside the transaction before the removal, for a table whose
2395
+ * row has dependants the schema requires removed with it ({@link deletePendingJoin}).
2396
+ *
1807
2397
  * NOTE: the stamp read is outside the transaction. A concurrent writer that removes
1808
2398
  * the row in between makes the signature bind a stamp that is no longer live; the
1809
2399
  * delete then matches nothing and the tombstone insert collides with the other
@@ -1814,7 +2404,7 @@ export class ControlDatabase {
1814
2404
  * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
1815
2405
  * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
1816
2406
  */
1817
- async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
2407
+ async deleteGuardedRow(table, keyValue, ownerKey, signMessage, alongside) {
1818
2408
  this.ensureInitialized();
1819
2409
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1820
2410
  const stampId = await this.queryStampId(table, keyValue, false);
@@ -1822,8 +2412,11 @@ export class ControlDatabase {
1822
2412
  log('delete %s: no row for %s (already absent)', table, keyValue);
1823
2413
  return false;
1824
2414
  }
1825
- const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
1826
- await this.inTransaction(`delete ${table}`, () => this.execGuardedRemoval(removal, ownerKey));
2415
+ const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2416
+ await this.inTransaction(`delete ${table}`, async () => {
2417
+ await alongside?.(stampId);
2418
+ await this.execGuardedRemoval(removal, ownerKey);
2419
+ });
1827
2420
  log('%s deleted: %s (stamp retired)', table, keyValue);
1828
2421
  this.notifyGuardedDelete(removal.ref);
1829
2422
  return true;
@@ -1831,7 +2424,7 @@ export class ControlDatabase {
1831
2424
  /**
1832
2425
  * Delete the row incarnation `removal` names and file its tombstone — the statement pair
1833
2426
  * every owner removal of a guarded row runs ({@link deleteGuardedRow},
1834
- * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
2427
+ * {@link deleteStrandAndPartyKey}, {@link rewritePendingJoin}). The caller supplies the
1835
2428
  * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
1836
2429
  * tombstone commits with it.
1837
2430
  *
@@ -1843,19 +2436,33 @@ export class ControlDatabase {
1843
2436
  */
1844
2437
  async execGuardedRemoval(removal, ownerKey) {
1845
2438
  const { tableName, rowKey, stampId } = removal.ref;
1846
- await this.db.exec(`
1847
- delete from CadreControl.${tableName}
1848
- with context OwnerKey = ?, Signature = ?
1849
- where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
2439
+ await this.db.exec(`
2440
+ delete from CadreControl.${tableName}
2441
+ with context OwnerKey = ?, Signature = ?
2442
+ where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
1850
2443
  `, [ownerKey, removal.signature, rowKey, stampId]);
1851
- // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1852
- // than leaning on the column default — the seat-at-zero rule is load-bearing for
1853
- // reissueRevocations' monotonic bump, so state it at the write site.
1854
- await this.db.exec(`
1855
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1856
- with context OwnerKey = ?, Signature = ?
1857
- values (?, ?, ?, 0)
1858
- `, [ownerKey, removal.revocationSignature, tableName, rowKey, stampId]);
2444
+ await this.execTombstone(removal.ref, removal.revocationSignature, ownerKey);
2445
+ }
2446
+ /**
2447
+ * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2448
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
2449
+ * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
2450
+ * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2451
+ * lock and, where one is needed, the transaction.
2452
+ *
2453
+ * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
2454
+ * than leaning on the column default — the seat-at-zero rule is load-bearing for
2455
+ * {@link reissueRevocations}' monotonic bump, so state it at the write site.
2456
+ *
2457
+ * `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
2458
+ * equal), so a node that receives the row by replication can tell which owner filed it.
2459
+ */
2460
+ async execTombstone(ref, revocationSignature, ownerKey) {
2461
+ await this.db.exec(`
2462
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
2463
+ with context OwnerKey = ?, Signature = ?
2464
+ values (?, ?, ?, 0, ?, ?)
2465
+ `, [ownerKey, revocationSignature, ref.tableName, ref.rowKey, ref.stampId, ownerKey, revocationSignature]);
1859
2466
  }
1860
2467
  /**
1861
2468
  * Fire the guarded-delete listener for one committed tombstone, swallowing (and
@@ -1937,10 +2544,10 @@ export class ControlDatabase {
1937
2544
  log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
1938
2545
  return false;
1939
2546
  }
1940
- const sql = `
1941
- delete from CadreControl.${table}
1942
- with context OwnerKey = null, Signature = null
1943
- where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
2547
+ const sql = `
2548
+ delete from CadreControl.${table}
2549
+ with context OwnerKey = null, Signature = null
2550
+ where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1944
2551
  `;
1945
2552
  if (table === 'CadrePeer') {
1946
2553
  // Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
@@ -1956,6 +2563,16 @@ export class ControlDatabase {
1956
2563
  // in one pass, hoist the notify to one refresh after the sweep instead.
1957
2564
  await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
1958
2565
  }
2566
+ else if (table === 'JoinRequest') {
2567
+ // The request's outcome row goes with it; the committed tombstone authorizes that delete
2568
+ // too. An outcome row held for a request this node never received (the request was
2569
+ // written alone and its outcome connected, before the growth-edge re-issue) is left: it
2570
+ // is unreadable, since every reader starts from the request.
2571
+ await this.lockedWithRetry(() => this.inTransaction('reap JoinRequest', async () => {
2572
+ await this.deleteJoinOutcomes(stampId);
2573
+ await this.db.exec(sql, [rowKey, stampId]);
2574
+ }), {}, 'reap-JoinRequest');
2575
+ }
1959
2576
  else {
1960
2577
  await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
1961
2578
  }
@@ -1992,9 +2609,9 @@ export class ControlDatabase {
1992
2609
  * a retired stamp, and which a revoked drone cannot produce at all. The trade is
1993
2610
  * recurring failure noise every cycle against removing the one stale row nobody else
1994
2611
  * reads from this node (every other node already filters it by tombstone). A revoked
1995
- * node therefore keeps its own copy of its own row; "a node that learns it has been
1996
- * revoked should shut itself down" is a distinct behaviour and its own ticket.
1997
- * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
2612
+ * node therefore keeps its own copy of its own row; learning that it was removed is a
2613
+ * separate behaviour (`CadreNode.removedFromCadre`, the `cadre:removed` event).
2614
+ * `ValidationKey`, `JoinedStrand` and `JoinRequest` have no self notion and are not
1998
2615
  * special-cased.
1999
2616
  *
2000
2617
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
@@ -2083,7 +2700,7 @@ export class ControlDatabase {
2083
2700
  // and every digest field is TEXT on both sides (see buildAuthorizationMessage).
2084
2701
  const signed = rows.map((row) => ({
2085
2702
  row,
2086
- signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2703
+ signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2087
2704
  }));
2088
2705
  return this.lockedWithRetry(async () => {
2089
2706
  // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
@@ -2103,11 +2720,11 @@ export class ControlDatabase {
2103
2720
  // output minted per row incarnation (generateStampId), so it identifies one
2104
2721
  // row across all TableName values. Do NOT "fix" this by adding TableName to
2105
2722
  // the where clause.
2106
- await this.db.exec(`
2107
- update CadreControl.Revocation
2108
- with context OwnerKey = ?, Signature = ?
2109
- set ReissuedAt = ?
2110
- where StampId = ?
2723
+ await this.db.exec(`
2724
+ update CadreControl.Revocation
2725
+ with context OwnerKey = ?, Signature = ?
2726
+ set ReissuedAt = ?
2727
+ where StampId = ?
2111
2728
  `, [ownerKey, signature, reissuedAt, row.stampId]);
2112
2729
  executed++;
2113
2730
  }
@@ -2149,19 +2766,14 @@ export class ControlDatabase {
2149
2766
  */
2150
2767
  async openRevocationLedger(ownerKey, signMessage) {
2151
2768
  this.ensureInitialized();
2152
- const { tableName, rowKey, stampId } = REVOCATION_LEDGER_MARKER;
2153
- const signature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]));
2769
+ const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2154
2770
  try {
2155
2771
  return await this.lockedWithRetry(async () => {
2156
2772
  if (await this.revocationLedgerFiled()) {
2157
2773
  return 'already-open';
2158
2774
  }
2159
- // Bare `exec`: already inside the write lock, which is NOT re-entrant.
2160
- await this.db.exec(`
2161
- insert into CadreControl.Revocation (TableName, RowKey, StampId)
2162
- with context OwnerKey = ?, Signature = ?
2163
- values (?, ?, ?)
2164
- `, [ownerKey, signature, tableName, rowKey, stampId]);
2775
+ // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
2776
+ await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
2165
2777
  log('Revocation ledger marker filed');
2166
2778
  return 'opened';
2167
2779
  }, {}, 'revocation-ledger-open');
@@ -2258,15 +2870,18 @@ export class ControlDatabase {
2258
2870
  this.controlWriteAbandonedListener = listener;
2259
2871
  }
2260
2872
  /**
2261
- * Run a `CadrePeer` row mutation and notify the membership listener once it has
2873
+ * Run a membership-relevant mutation and notify the membership listener once it has
2262
2874
  * COMMITTED.
2263
2875
  *
2264
- * EVERY `CadrePeer` writer goes through here, with one documented exception
2265
- * ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
2266
- * the party-membership snapshot refresh automatic rather than a caller obligation. A
2876
+ * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
2877
+ * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
2878
+ * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
2879
+ * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
2880
+ * which seats both. That is what makes the party-membership snapshot refresh automatic
2881
+ * rather than a caller obligation. The name predates the `OwnerKey` writers. A
2267
2882
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
2268
- * event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
2269
- * `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
2883
+ * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
2884
+ * builds, and services constructed outside `CadreNode` entirely, never get
2270
2885
  * callbacks wired), so this is the one point on the write path that cannot be bypassed.
2271
2886
  *
2272
2887
  * `body` owns its own transaction if it needs one and must COMMIT before returning, so
@@ -2447,7 +3062,7 @@ export class ControlDatabase {
2447
3062
  return;
2448
3063
  }
2449
3064
  throw new Error(`mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
2450
- 'CadrePeer write must commit before the membership listener runs; move the ' +
3065
+ 'membership write must commit before the membership listener runs; move the ' +
2451
3066
  'mutateCadrePeer wrapper out to enclose the commit');
2452
3067
  }
2453
3068
  /**
@@ -2526,17 +3141,17 @@ export class ControlDatabase {
2526
3141
  const strandIdField = options.strandId ?? '';
2527
3142
  // Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
2528
3143
  // Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
2529
- const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
3144
+ const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
2530
3145
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
2531
3146
  ]);
2532
3147
  const signature = signMessage(message);
2533
3148
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
2534
3149
  // signed source-of-truth and the stored value are produced once. StampId is a real,
2535
3150
  // unique column (single-use anti-replay), no longer a context value.
2536
- await this.execWrite(`
2537
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
2538
- with context OwnerKey = ?, Signature = ?
2539
- values (?, ?, ?, ?, ?, ?, ?)
3151
+ await this.execWrite(`
3152
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3153
+ with context OwnerKey = ?, Signature = ?
3154
+ values (?, ?, ?, ?, ?, ?, ?)
2540
3155
  `, [
2541
3156
  ownerKey, signature,
2542
3157
  token, sAppId,
@@ -2605,10 +3220,10 @@ export class ControlDatabase {
2605
3220
  // Hard-coded open + keyless + explicit null FounderOwnerKey: the
2606
3221
  // consent branch admits no other shape (no signature means no
2607
3222
  // trustworthy founder machine to record — see the schema's NOTE).
2608
- await this.db.exec(`
2609
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
2610
- with context OwnerKey = null, Signature = null
2611
- values (?, 'o', null, ?, null)
3223
+ await this.db.exec(`
3224
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
3225
+ with context OwnerKey = null, Signature = null
3226
+ values (?, 'o', null, ?, null)
2612
3227
  `, [strandId, strandStampId]);
2613
3228
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
2614
3229
  // carrying the strand's stamp so it authorizes THIS row and no other.
@@ -2728,10 +3343,10 @@ export class ControlDatabase {
2728
3343
  // robustness/consistency change, matching the strand layer's `consumeInvite`,
2729
3344
  // not a fix for an observable mis-ordering.
2730
3345
  const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
2731
- await this.db.exec(`
2732
- insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
2733
- with context Now = ?, ValidationKey = ?, ValidationSignature = ?
2734
- values (?, ?, ?, ?, ?, ?, ?)
3346
+ await this.db.exec(`
3347
+ insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
3348
+ with context Now = ?, ValidationKey = ?, ValidationSignature = ?
3349
+ values (?, ?, ?, ?, ?, ?, ?)
2735
3350
  `, [
2736
3351
  nowCanonical, opts.validationKey, opts.validationSignature,
2737
3352
  opts.token, opts.usageStampId, opts.peerKey,
@@ -2749,18 +3364,18 @@ export class ControlDatabase {
2749
3364
  */
2750
3365
  async queryFormationInvite(token, retry = true) {
2751
3366
  this.ensureInitialized();
2752
- const sql = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?';
2753
- for (const row of await this.readRows(sql, [token], 'formation-invite', retry)) {
2754
- return {
2755
- token: row.Token,
2756
- sAppId: row.sAppId,
2757
- expiresAtMs: parseNullableStoredDatetimeMs(row.ExpiresAt),
2758
- totalUses: row.TotalUses ?? null,
2759
- validationUrl: row.ValidationUrl ?? null,
2760
- strandId: row.StrandId ?? null,
2761
- };
2762
- }
2763
- return null;
3367
+ const rows = await this.readRows(`${FORMATION_INVITE_SELECT_SQL} where Token = ?`, [token], 'formation-invite', retry);
3368
+ return rows.length === 0 ? null : formationInviteRowOf(rows[0]);
3369
+ }
3370
+ /**
3371
+ * Every `FormationInvite` row, expired and spent ones included, in the shape
3372
+ * {@link queryFormationInvite} returns one. Read by a strand move
3373
+ * (`exportStrandBundle`), which decides per row what is still redeemable.
3374
+ */
3375
+ async queryFormationInvites() {
3376
+ this.ensureInitialized();
3377
+ const rows = await this.readRows(FORMATION_INVITE_SELECT_SQL, undefined, 'formation-invites');
3378
+ return rows.map(formationInviteRowOf);
2764
3379
  }
2765
3380
  /**
2766
3381
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
@@ -2860,6 +3475,410 @@ export class ControlDatabase {
2860
3475
  }
2861
3476
  return false;
2862
3477
  }
3478
+ // ── Cadre invitations (CadreInvite / CadreInviteUsage) ─────────────────────────────────
3479
+ /**
3480
+ * Mint and insert an owner-signed `CadreInvite`: an invitation to join THIS cadre that any
3481
+ * member machine can later redeem on the owner's behalf ({@link redeemCadreInvite}).
3482
+ *
3483
+ * The owner signs the whole row ({@link cadreInviteAddMessage}) — `ExpiresAt` through
3484
+ * {@link canonicalDatetime} so the signed segment byte-matches the stored `datetime`, as
3485
+ * {@link insertFormationInvite} does — and the stored `IssuerKey`/`IssuerSig` are pinned to
3486
+ * that signer by `CadreInvite.AuthorizedInsert`. The signature is part of the row because
3487
+ * the row travels: the returned {@link CadreInviteRow} is what the invitation bundle
3488
+ * carries to the holder, and {@link seatCadreInvite} re-inserts it verbatim on a member
3489
+ * that has not received it by replication.
3490
+ *
3491
+ * `key` is the invitation's ed25519 PUBLIC key; the caller minted the pair and hands the
3492
+ * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
3493
+ * same shape).
3494
+ *
3495
+ * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
3496
+ * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
3497
+ * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
3498
+ * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
3499
+ * @param invite.totalUses - how many redemptions it allows; omit for unlimited
3500
+ * @returns the row as stored, `expiresAt` in its canonical form
3501
+ */
3502
+ async insertCadreInvite(invite, ownerKey, signMessage) {
3503
+ this.ensureInitialized();
3504
+ const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
3505
+ const signed = {
3506
+ key,
3507
+ peerId: invite.peerId ?? null,
3508
+ grantsOwner: invite.grantsOwner,
3509
+ expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
3510
+ totalUses: invite.totalUses ?? null,
3511
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
3512
+ };
3513
+ const row = {
3514
+ ...signed,
3515
+ issuerKey: ownerKey,
3516
+ issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
3517
+ };
3518
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
3519
+ log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
3520
+ return row;
3521
+ }
3522
+ /**
3523
+ * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
3524
+ * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
3525
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
3526
+ *
3527
+ * @returns `true` when this call inserted the row, `false` when it was already present.
3528
+ * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
3529
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
3530
+ * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3531
+ */
3532
+ async seatCadreInvite(row) {
3533
+ this.ensureInitialized();
3534
+ try {
3535
+ return await this.lockedWithRetry(async () => {
3536
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
3537
+ if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
3538
+ log('CadreInvite %s already seated; seat skipped', row.key);
3539
+ return false;
3540
+ }
3541
+ await this.execCadreInviteInsert(row);
3542
+ log('Cadre invitation seated from its bundle: %s', row.key);
3543
+ return true;
3544
+ }, {}, 'cadre-invite-seat');
3545
+ }
3546
+ catch (error) {
3547
+ if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
3548
+ throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
3549
+ }
3550
+ throw error;
3551
+ }
3552
+ }
3553
+ /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
3554
+ async execCadreInviteInsert(row) {
3555
+ await this.db.exec(`
3556
+ insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
3557
+ with context OwnerKey = ?, Signature = ?
3558
+ values (?, ?, ?, ?, ?, ?, ?, ?)
3559
+ `, [
3560
+ row.issuerKey, row.issuerSig,
3561
+ row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
3562
+ row.issuerKey, row.issuerSig, row.stampId,
3563
+ ]);
3564
+ }
3565
+ /**
3566
+ * Read one `CadreInvite` row by key, or null when absent. Withdrawn rows are returned too —
3567
+ * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
3568
+ * question, and `CadreInviteUsage.Authorized`'s at redemption.
3569
+ *
3570
+ * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
3571
+ * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
3572
+ */
3573
+ async queryCadreInvite(key, retry = true) {
3574
+ this.ensureInitialized();
3575
+ for (const row of await this.readRows(`${CADRE_INVITE_SELECT_SQL} where Key = ?`, [key], 'cadre-invite', retry)) {
3576
+ return cadreInviteRowOf(row);
3577
+ }
3578
+ return null;
3579
+ }
3580
+ /**
3581
+ * Every `CadreInvite` row this node holds, withdrawn and expired ones included (see
3582
+ * {@link queryCadreInvite}). Read by the membership predicate when at least one `CadrePeer`
3583
+ * row is invitation-admitted; `retry: false` is forwarded from
3584
+ * {@link CadreNode.listAuthorizedMembers} on the membership-gate refresh path for
3585
+ * {@link queryCadrePeers}' reason.
3586
+ */
3587
+ async queryCadreInvites(retry = true) {
3588
+ this.ensureInitialized();
3589
+ const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
3590
+ return rows.map(cadreInviteRowOf);
3591
+ }
3592
+ /**
3593
+ * Every `CadreInviteUsage` row this node holds — the redemption records the membership
3594
+ * predicate verifies invitation-admitted rows against. Same `retry` contract as
3595
+ * {@link queryCadreInvites}.
3596
+ */
3597
+ async queryCadreInviteUsages(retry = true) {
3598
+ this.ensureInitialized();
3599
+ const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
3600
+ return rows.map(cadreInviteUsageRowOf);
3601
+ }
3602
+ /**
3603
+ * How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
3604
+ * through the `CadreInviteUsageByInvite` index. As {@link countFormationUsage}: a permissive
3605
+ * PRE-check, not the cap — the cap is `CadreInviteUsage.Authorized`'s own committed count.
3606
+ *
3607
+ * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
3608
+ */
3609
+ async countCadreInviteUsage(key, retry = true) {
3610
+ this.ensureInitialized();
3611
+ const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
3612
+ for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
3613
+ return row.Count ?? 0;
3614
+ }
3615
+ return 0;
3616
+ }
3617
+ /**
3618
+ * Is any cadre invitation still redeemable — not withdrawn, unexpired, with uses below its
3619
+ * `TotalUses`, and issued by a current owner? The conditions `CadreInviteUsage.Authorized`
3620
+ * applies, read ahead of time so the control-network connection gate can answer "does this
3621
+ * node expect a stranger?" with no key to ask about, exactly as
3622
+ * {@link hasOutstandingFormationInvite} answers it for strand formation. The expiry
3623
+ * comparison is `expiresAtMs <= now`, so an invitation the redemption would refuse never
3624
+ * holds the gate open.
3625
+ *
3626
+ * Withdrawal is read through {@link queryRevokedStamps}, the per-table retired-stamp seam
3627
+ * every other revocation-aware reader uses (and the one an isolated node can still answer).
3628
+ */
3629
+ async hasLiveCadreInvite(nowMs = Date.now()) {
3630
+ this.ensureInitialized();
3631
+ // NOTE: getOwnerKeys reads the retired OwnerKey stamps first, so this gate path scans
3632
+ // Revocation twice (once per table, the two in parallel). If the stranger path's decision
3633
+ // ever nears the admission gate's 2 s deadline, read the retired stamps once for both.
3634
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
3635
+ // NOTE: scans every CadreInvite row (expired and withdrawn ones included, since rows are
3636
+ // never deleted) on the stranger path of an inbound connection. Cadre-scale invitation
3637
+ // counts make that free today; if a long-lived cadre accumulates thousands and inbound
3638
+ // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
3639
+ // first needs every device they admitted re-vouched, so the chain no longer needs them).
3640
+ const metered = [];
3641
+ let unlimitedLive = false;
3642
+ for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
3643
+ const open = cadreInviteStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
3644
+ if (!open) {
3645
+ continue;
3646
+ }
3647
+ const totalUses = row.TotalUses ?? null;
3648
+ if (totalUses === null) {
3649
+ unlimitedLive = true;
3650
+ }
3651
+ else {
3652
+ metered.push({ key: row.Key, totalUses });
3653
+ }
3654
+ }
3655
+ if (unlimitedLive) {
3656
+ return true;
3657
+ }
3658
+ // NOTE: one retried read per metered invitation, each with its own read budget, on a path
3659
+ // the admission gate awaits under a 2 s fail-open deadline — the same shape and the same
3660
+ // revisit condition as hasOutstandingFormationInvite's loop.
3661
+ for (const invite of metered) {
3662
+ if (await this.countCadreInviteUsage(invite.key) < invite.totalUses) {
3663
+ return true;
3664
+ }
3665
+ }
3666
+ return false;
3667
+ }
3668
+ /**
3669
+ * Is this one cadre invitation still redeemable here — the conditions
3670
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
3671
+ * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
3672
+ * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
3673
+ * judged by whichever owner it names as issuer. A permissive pre-check like
3674
+ * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
3675
+ * what enforce every condition.
3676
+ */
3677
+ async isCadreInviteLive(invite, nowMs = Date.now()) {
3678
+ this.ensureInitialized();
3679
+ const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
3680
+ if (row === null) {
3681
+ return false;
3682
+ }
3683
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
3684
+ if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
3685
+ return false;
3686
+ }
3687
+ return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
3688
+ }
3689
+ /**
3690
+ * Every `CadreInvite` row this node holds with its standing: how many redemptions are
3691
+ * recorded, whether it is withdrawn, and whether it is still redeemable — the same
3692
+ * conditions {@link hasLiveCadreInvite} applies, answered per row for an owner's listing
3693
+ * rather than short-circuited. One usage count per row, so this is the owner's
3694
+ * occasional read, not a gate's.
3695
+ */
3696
+ async listCadreInviteStatuses(nowMs = Date.now()) {
3697
+ this.ensureInitialized();
3698
+ const [invites, withdrawnStamps, owners] = await Promise.all([
3699
+ this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
3700
+ ]);
3701
+ const statuses = [];
3702
+ // NOTE: one retried count read per invitation, withdrawn and expired rows included, since
3703
+ // rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
3704
+ // usage counts in one statement or skip the count for rows that are no longer open.
3705
+ for (const invite of invites) {
3706
+ const usesRecorded = await this.countCadreInviteUsage(invite.key);
3707
+ const withdrawn = withdrawnStamps.has(invite.stampId);
3708
+ const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
3709
+ && (invite.totalUses === null || usesRecorded < invite.totalUses);
3710
+ statuses.push({ invite, live, withdrawn, usesRecorded });
3711
+ }
3712
+ return statuses;
3713
+ }
3714
+ /**
3715
+ * Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
3716
+ * stamp, with the row kept. The row stays because every device the invitation admitted
3717
+ * proves its membership through it (`CadrePeer.AuthorizedInsert`'s consent branch,
3718
+ * `verifyInvitationAdmission`); `CadreInvite.NoDelete` refuses a delete, and
3719
+ * `Revocation.RowIsGone` admits a `'CadreInvite'` tombstone while the row lives. Afterwards
3720
+ * `CadreInviteUsage.Authorized` refuses every further redemption, and `CadreInvite.NotRevoked`
3721
+ * refuses re-seating the row from the holder's signed copy on any node holding the
3722
+ * tombstone.
3723
+ *
3724
+ * Idempotent: a no-op (no throw, nothing written) when the row is absent locally or already
3725
+ * withdrawn — `false` then, `true` when this call filed the tombstone. The owner must hold the
3726
+ * row to withdraw it, since the tombstone names the row's stamp.
3727
+ *
3728
+ * Fires the guarded-delete listener like a removal does, so a withdrawal committed while the
3729
+ * node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
3730
+ */
3731
+ async withdrawCadreInvite(key, ownerKey, signMessage) {
3732
+ this.ensureInitialized();
3733
+ const filed = await this.lockedWithRetry(async () => {
3734
+ const [withdrawal] = await this.signCadreInviteWithdrawals([key], signMessage);
3735
+ if (withdrawal === undefined) {
3736
+ return null;
3737
+ }
3738
+ await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
3739
+ return withdrawal.ref;
3740
+ }, {}, 'cadre-invite-withdraw');
3741
+ if (filed === null) {
3742
+ return false;
3743
+ }
3744
+ log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
3745
+ this.notifyGuardedDelete(filed);
3746
+ return true;
3747
+ }
3748
+ /**
3749
+ * The withdrawal tombstones for the `CadreInvite` rows among `keys` that are held here and
3750
+ * not yet withdrawn, signed and ready for {@link execTombstone}: the body
3751
+ * {@link withdrawCadreInvite} files one of and {@link removeMachine} files several of in one
3752
+ * transaction. Inside the locked body (every read `retry: false`); an absent or already
3753
+ * withdrawn key is logged and left out.
3754
+ */
3755
+ async signCadreInviteWithdrawals(keys, signMessage) {
3756
+ if (keys.length === 0) {
3757
+ return [];
3758
+ }
3759
+ const withdrawn = await this.queryRevokedStamps('CadreInvite', false);
3760
+ const withdrawals = [];
3761
+ for (const key of keys) {
3762
+ const stampId = await this.queryStampId('CadreInvite', key, false);
3763
+ if (stampId === null) {
3764
+ log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
3765
+ continue;
3766
+ }
3767
+ if (withdrawn.has(stampId)) {
3768
+ log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
3769
+ continue;
3770
+ }
3771
+ const ref = { tableName: 'CadreInvite', rowKey: key, stampId };
3772
+ withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
3773
+ }
3774
+ return withdrawals;
3775
+ }
3776
+ /**
3777
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
3778
+ * when the invitation grants ownership, as an owner — with NO owner signature. One
3779
+ * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
3780
+ * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
3781
+ * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
3782
+ * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
3783
+ * redemption and none can be held in reserve. The invitation must already be committed
3784
+ * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
3785
+ *
3786
+ * The two signatures are the holder's and the device's, minted on the device over
3787
+ * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
3788
+ * verifies them (by writing the row). `peerId` must be the identity multihash of `peerKey`:
3789
+ * the schema cannot derive it, so a writer asserts the pair and every reader re-derives it
3790
+ * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
3791
+ *
3792
+ * Idempotent for a retry after a dropped reply: when the device's row is already present
3793
+ * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
3794
+ * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
3795
+ * owner-granting invitation is completed: the usage row then names the existing peer stamp.
3796
+ * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
3797
+ * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
3798
+ *
3799
+ * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
3800
+ * every read passes `retry: false` and every stamp is minted per attempt, so a
3801
+ * {@link lockedWithRetry} re-run starts clean.
3802
+ *
3803
+ * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
3804
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
3805
+ */
3806
+ async redeemCadreInvite(params) {
3807
+ this.ensureInitialized();
3808
+ const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
3809
+ const nowMs = params.nowMs ?? Date.now();
3810
+ const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
3811
+ log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
3812
+ return this.mutateCadrePeer('cadre-invite-redeem', async () => {
3813
+ const invite = await this.queryCadreInvite(inviteKey, false);
3814
+ if (invite === null) {
3815
+ throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
3816
+ }
3817
+ const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
3818
+ if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
3819
+ log('redeemCadreInvite: %s already admitted; nothing written', peerId);
3820
+ return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
3821
+ }
3822
+ await this.assertCadreInviteSeatRemains(invite);
3823
+ // Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
3824
+ // the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
3825
+ // attempt rather than re-presenting a stamp the usage row may already name.
3826
+ const peerStampId = seated.peerStampId ?? generateStampId(peerId);
3827
+ const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
3828
+ const nowCanonical = await canonicalDatetime(this.db, nowMs);
3829
+ await this.inTransaction('cadre-invite-redeem', async () => {
3830
+ if (seated.peerStampId === null) {
3831
+ await this.db.exec(`
3832
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
3833
+ with context OwnerKey = null, Signature = null
3834
+ values (?, ?, ?, ?, null, ?, ?, null, ?)
3835
+ `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
3836
+ }
3837
+ if (ownerStampId !== null && seated.ownerStampId === null) {
3838
+ await this.db.exec(`
3839
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
3840
+ with context OwnerKey = null, Signature = null
3841
+ values (?, ?, ?, null, ?)
3842
+ `, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
3843
+ }
3844
+ await this.db.exec(`
3845
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
3846
+ with context Now = ?
3847
+ values (?, ?, ?, ?, ?, ?, ?, ?)
3848
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
3849
+ });
3850
+ log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
3851
+ return { alreadyMember: false, peerStampId, ownerStampId };
3852
+ });
3853
+ }
3854
+ /**
3855
+ * The rows a redemption would seat that are already present: the device's `CadrePeer`
3856
+ * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
3857
+ * reads ({@link queryStampId}), so a physically present row counts; inside the locked
3858
+ * body, so never retried.
3859
+ */
3860
+ async readSeatedAdmission(peerId, peerKey, grantsOwner) {
3861
+ return {
3862
+ peerStampId: await this.queryStampId('CadrePeer', peerId, false),
3863
+ ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
3864
+ };
3865
+ }
3866
+ /**
3867
+ * Refuse a redemption that would consume a seat the cadre invitation does not have, by
3868
+ * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
3869
+ * {@link assertSeatRemains}, for the same reason: the schema's cap clause refuses as a
3870
+ * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
3871
+ * committed count inside the locked body, so on a same-node race the loser is refused here.
3872
+ */
3873
+ async assertCadreInviteSeatRemains(invite) {
3874
+ if (invite.totalUses === null) {
3875
+ return;
3876
+ }
3877
+ const used = await this.countCadreInviteUsage(invite.key, false);
3878
+ if (used >= invite.totalUses) {
3879
+ throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
3880
+ }
3881
+ }
2863
3882
  /**
2864
3883
  * Close the database and cleanup resources.
2865
3884
  *