@serfab/cadre-core 1.13.0 → 1.14.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 (120) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +523 -180
  5. package/dist/cadre-node.js +921 -293
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +190 -151
  42. package/dist/membership-connection-gater.js +235 -173
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-formation-protocol.js +1 -1
  78. package/dist/strand-formation-protocol.js.map +1 -1
  79. package/dist/strand-revocation-enforcer.d.ts +11 -11
  80. package/dist/strand-revocation-enforcer.js +11 -11
  81. package/dist/strand-wake-protocol.d.ts +13 -16
  82. package/dist/strand-wake-protocol.js +13 -20
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/trusted-owner-store.d.ts +35 -5
  85. package/dist/trusted-owner-store.js +50 -8
  86. package/dist/trusted-owner-store.js.map +1 -1
  87. package/dist/types.d.ts +302 -87
  88. package/dist/types.js.map +1 -1
  89. package/package.json +8 -7
  90. package/src/cadre-invite-protocol.ts +864 -0
  91. package/src/cadre-node.ts +9381 -8686
  92. package/src/claim-proof.ts +147 -0
  93. package/src/control-authorization.ts +203 -117
  94. package/src/control-cohort.ts +1 -1
  95. package/src/control-database.ts +1216 -218
  96. package/src/control-protocol-guard.ts +348 -0
  97. package/src/control-schema.ts +812 -1078
  98. package/src/control-stream.ts +26 -7
  99. package/src/delegate-admission.ts +1 -1
  100. package/src/device-token.ts +14 -10
  101. package/src/ed25519-key.ts +28 -1
  102. package/src/index.ts +84 -10
  103. package/src/link-budget.ts +16 -32
  104. package/src/membership-connection-gater.ts +637 -570
  105. package/src/node-claim-payload.ts +125 -0
  106. package/src/owner-anchor-sync.ts +171 -0
  107. package/src/peer-authorization.ts +258 -29
  108. package/src/peer-dial.ts +22 -1
  109. package/src/peer-join-backfill.ts +56 -11
  110. package/src/peer-record.ts +16 -11
  111. package/src/pending-join-runner.ts +729 -735
  112. package/src/push-fanout.ts +8 -2
  113. package/src/seed-bootstrap.ts +344 -321
  114. package/src/seed-trust-policy.ts +353 -140
  115. package/src/strand-addr-protocol.ts +26 -35
  116. package/src/strand-formation-protocol.ts +1 -1
  117. package/src/strand-revocation-enforcer.ts +11 -11
  118. package/src/strand-wake-protocol.ts +13 -23
  119. package/src/trusted-owner-store.ts +242 -171
  120. package/src/types.ts +2471 -2242
@@ -1,18 +1,19 @@
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 type { VTablePluginInfo, FunctionPluginInfo, SqlParameters, SqlValue } from '@quereus/quereus';
5
5
  import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
6
6
  import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
7
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
8
  import type { Libp2p } from '@libp2p/interface';
9
9
  import type { IRepo } from '@optimystic/db-core';
10
- import type { StrandRow, PendingJoinRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
10
+ import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, SeedPeer } from './types.js';
11
11
  import { CONTROL_SCHEMA } from './control-schema.js';
12
12
  import { canonicalDatetime } from './canonical-datetime.js';
13
- import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
14
- import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
15
- import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
13
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
14
+ import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDomain, ControlAction, CadreInviteSignedFields } from './control-authorization.js';
15
+ import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
16
+ import { verifyCadreInviteRow } from './peer-authorization.js';
16
17
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
17
18
  import type { ControlWriteRetryOptions } from './control-write-retry.js';
18
19
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
@@ -94,13 +95,15 @@ export class FormationAbortedError extends Error {
94
95
  }
95
96
 
96
97
  /**
97
- * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
98
- * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
99
- * seat.
98
+ * The invitation's seat budget is already spent: the count of recorded usage rows for the
99
+ * invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
100
+ * is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
101
+ * redeemed against.
100
102
  *
101
103
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
102
104
  * the write, inside {@link ControlDatabase.redeemInvitation} /
103
- * {@link ControlDatabase.recordFormationUsage}, and off the unlocked pre-check
105
+ * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
106
+ * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
104
107
  * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
105
108
  * pass — and off nothing else. It exists because
106
109
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
@@ -124,6 +127,30 @@ export class InvitationExhaustedError extends Error {
124
127
  }
125
128
  }
126
129
 
130
+ /**
131
+ * {@link ControlDatabase.seatCadreInvite} was refused by `CadreInvite.AuthorizedInsert`: the
132
+ * row's `IssuerKey` is not an owner on THIS node, or its `IssuerSig` does not verify. The
133
+ * common cause is the first — the issuing owner's `OwnerKey` row has not replicated here yet,
134
+ * or that owner has since been removed — which is why the redemption protocol maps this to a
135
+ * retryable code; a tampered row fails the same way and simply never succeeds.
136
+ */
137
+ export class CadreInviteIssuerUnknownError extends Error {
138
+ constructor(
139
+ readonly inviteKey: string,
140
+ readonly issuerKey: string,
141
+ options?: { cause?: unknown }
142
+ ) {
143
+ super(
144
+ `Cadre invitation ${inviteKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`,
145
+ options
146
+ );
147
+ this.name = 'CadreInviteIssuerUnknownError';
148
+ }
149
+ }
150
+
151
+ /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
152
+ const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
153
+
127
154
  /**
128
155
  * What a recorded `FormationUsage` row came out as.
129
156
  *
@@ -167,14 +194,14 @@ function parseNullableStoredDatetimeMs(value: string | number | null | undefined
167
194
  * The message is a SINGLE framed SHA-256 digest over the ordered field vector from
168
195
  * {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
169
196
  * encoding): two fixed literals — the domain tag naming the table rule and the action
170
- * tag — followed by the row fields in the schema's fixed order, with the single-use
171
- * StampId as the final field where the table has one:
197
+ * tag — then the party id, then the row fields in the schema's fixed order, with the
198
+ * single-use StampId as the final field where the table has one:
172
199
  *
173
- * message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
200
+ * message = sha256(encodeFields([domain, action, partyId, field_1, ..., StampId])) // raw digest bytes
174
201
  *
175
202
  * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
176
203
  * verify the identical bytes with one variadic call
177
- * (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
204
+ * (`verify(digest('CadreControl.X', 'add', party_id(), field_1, ...), context.Signature, A.Key, 'ed25519')`):
178
205
  * SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
179
206
  * base64url input encoding decodes back to those raw bytes — so signer and verifier
180
207
  * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
@@ -182,16 +209,57 @@ function parseNullableStoredDatetimeMs(value: string | number | null | undefined
182
209
  * type tags agree. Binding the row contents closes captured-stamp replay; the leading
183
210
  * domain/action tags scope the signature to ONE table rule, so an approval minted for
184
211
  * one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
185
- * longer double as an OwnerKey enrollment). Single source of truth: every signed writer
186
- * (and every test/harness signer) MUST build the message through this function with the
187
- * schema's tags and field order, or `verify` will reject the row.
212
+ * longer double as an OwnerKey enrollment); the party id scopes it to ONE party, so two
213
+ * parties sharing an owner key never accept each other's approvals. `partyId` is the
214
+ * party the signer is configured for, and the SQL side reads its own through the
215
+ * node-local `party_id()` ({@link registerPartyIdFunction}). Single source of truth:
216
+ * every signed writer (and every test/harness signer) MUST build the message through
217
+ * this function with the schema's tags and field order, or `verify` will reject the row.
188
218
  */
189
219
  export function buildAuthorizationMessage(
190
- domain: ControlDomain,
220
+ domain: BoundControlDomain,
191
221
  action: ControlAction,
222
+ partyId: string,
192
223
  rowFields: string[],
193
224
  ): Uint8Array {
194
- return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes') as Uint8Array;
225
+ return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'bytes') as Uint8Array;
226
+ }
227
+
228
+ /**
229
+ * {@link buildAuthorizationMessage} for the two party-free digests (`UnboundControlDomain`):
230
+ * the same framed digest with no party id after the tags.
231
+ */
232
+ function buildUnboundAuthorizationMessage(
233
+ domain: UnboundControlDomain,
234
+ action: ControlAction,
235
+ rowFields: string[],
236
+ ): Uint8Array {
237
+ return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes') as Uint8Array;
238
+ }
239
+
240
+ /**
241
+ * Register the zero-argument scalar `party_id()` on `db`: the party id this machine is
242
+ * configured for, which every party-bound `digest(...)` in the control schema puts third.
243
+ * Node-local by design — a `context` value would be the writer's choice (a cohort member
244
+ * that re-ran the statement would replay it as a literal), and a stored `Party` table would
245
+ * be replicated data the schema already treats as untrusted. A cohort member that re-runs a
246
+ * statement therefore evaluates its OWN party id, and the schema text stays identical for
247
+ * every party. Each `Database` carries its own registration, so a process serving two
248
+ * parties holds two.
249
+ *
250
+ * NOTE: flagged deterministic, so the planner may fold the call into a plan; plans are
251
+ * per `Database` today. If Quereus ever shares plans across `Database` instances, drop
252
+ * the DETERMINISTIC flag or one party's id would be folded into another's checks.
253
+ *
254
+ * `ControlDatabase.initialize` calls this before hydrating persisted schemas, because a
255
+ * warm restart parses the persisted CHECK text, which calls it. Exported for specs that
256
+ * build their own `Database` around a copy of a bound constraint.
257
+ */
258
+ export function registerPartyIdFunction(db: Database, partyId: string): void {
259
+ db.registerFunction(createScalarFunction(
260
+ { name: 'party_id', numArgs: 0, flags: FunctionFlags.UTF8 | FunctionFlags.DETERMINISTIC, returnType: TEXT_RETURN },
261
+ () => partyId,
262
+ ));
195
263
  }
196
264
 
197
265
  /**
@@ -209,6 +277,10 @@ export function buildAuthorizationMessage(
209
277
  * that same nonce, then pass BOTH the nonce and the signature to
210
278
  * {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
211
279
  * signing one nonce and inserting another fails the CHECK.
280
+ *
281
+ * No party id, unlike every owner digest: the approver is sent only these five fields
282
+ * (`formation-approval.ts`), and the `token` it binds is a random secret seated in one
283
+ * party's `FormationInvite` table (the schema's `FormationUsage.Authorized` comment).
212
284
  */
213
285
  export function formationVouchMessage(fields: {
214
286
  token: string;
@@ -217,7 +289,7 @@ export function formationVouchMessage(fields: {
217
289
  peerKey: string;
218
290
  disclosure: string;
219
291
  }): Uint8Array {
220
- return buildAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
292
+ return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
221
293
  fields.token, fields.usageStampId, fields.strandId, fields.peerKey, fields.disclosure,
222
294
  ]);
223
295
  }
@@ -238,7 +310,9 @@ export function formationVouchMessage(fields: {
238
310
  * the strand when it signs (a bound invite's host strand arrives only in the result
239
311
  * frame; an unbound strand is minted by the responder). The responder cannot
240
312
  * substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
241
- * and an unbound redemption mints a fresh strand, so there is no victim to name.
313
+ * and an unbound redemption mints a fresh strand, so there is no victim to name. The
314
+ * party id is absent for the same reason (it too arrives only in the result frame) and
315
+ * the `token` scopes the digest to one party instead, as for {@link formationVouchMessage}.
242
316
  */
243
317
  export function formationConsentMessage(fields: {
244
318
  token: string;
@@ -246,11 +320,56 @@ export function formationConsentMessage(fields: {
246
320
  peerKey: string;
247
321
  disclosure: string;
248
322
  }): Uint8Array {
249
- return buildAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
323
+ return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
250
324
  fields.token, fields.usageStampId, fields.peerKey, fields.disclosure,
251
325
  ]);
252
326
  }
253
327
 
328
+ /**
329
+ * The exact bytes an owner signs to seat a `CadreInvite` row — the TS mirror of the `'add'`
330
+ * digest in `CadreInvite.AuthorizedInsert`, over the whole row in the schema's order with
331
+ * nullable columns as `''` (`cadreInviteRowFields`, shared with the base64url verifier
332
+ * `peer-authorization.ts`'s `cadreInviteAddDigest`). `expiresAt` must already be the stored
333
+ * canonical `datetime` string ({@link canonicalDatetime}), as {@link ControlDatabase.insertCadreInvite}
334
+ * produces it, or the signed segment will not match what the CHECK sees after coercion.
335
+ */
336
+ export function cadreInviteAddMessage(partyId: string, row: CadreInviteSignedFields): Uint8Array {
337
+ return buildAuthorizationMessage('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
338
+ }
339
+
340
+ /** The fields both redemption signatures cover; see {@link cadreInviteRedeemMessage}. */
341
+ export interface CadreInviteRedemptionFields {
342
+ inviteKey: string;
343
+ /** The single-use nonce the redeeming device minted for this redemption (`generateStampId`). */
344
+ usageStampId: string;
345
+ /** The redeeming device's own ed25519 public key (base64url). */
346
+ peerKey: string;
347
+ }
348
+
349
+ /**
350
+ * The exact bytes the HOLDER of a cadre invitation signs, with the invitation's private key,
351
+ * to redeem it once — the TS mirror of the `'redeem'` digest `CadreInviteUsage.InvitePossessed`
352
+ * verifies against the invitation's public key. Binding the device and the nonce means a
353
+ * captured redemption can admit no other device and spend no other use.
354
+ */
355
+ export function cadreInviteRedeemMessage(partyId: string, fields: CadreInviteRedemptionFields): Uint8Array {
356
+ return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'redeem', partyId, [
357
+ fields.inviteKey, fields.usageStampId, fields.peerKey,
358
+ ]);
359
+ }
360
+
361
+ /**
362
+ * The exact bytes the DEVICE signs, with its own key, to consent to being admitted by one
363
+ * redemption — the TS mirror of the `'consent'` digest `CadreInviteUsage.PeerConsented`
364
+ * verifies against `PeerKey`. Same fields as {@link cadreInviteRedeemMessage} under a
365
+ * distinct action tag, so neither signature can stand in for the other.
366
+ */
367
+ export function cadreInviteConsentMessage(partyId: string, fields: CadreInviteRedemptionFields): Uint8Array {
368
+ return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'consent', partyId, [
369
+ fields.inviteKey, fields.usageStampId, fields.peerKey,
370
+ ]);
371
+ }
372
+
254
373
  /**
255
374
  * Minimal interface for the CollectionFactory returned by the optimystic plugin.
256
375
  * We only need the methods we actually use.
@@ -296,12 +415,17 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
296
415
  Strand: 'Id',
297
416
  StrandPartyKey: 'Id',
298
417
  JoinedStrand: 'Id',
299
- PendingJoin: 'Id',
418
+ JoinRequest: 'Id',
300
419
  DeviceToken: 'PeerId',
420
+ CadreInvite: 'Key',
301
421
  };
302
422
 
303
- /** A guarded table an owner may remove a row from (no production `OwnerKey` removal path exists yet). */
304
- type RemovableTable = Exclude<RevocableTable, 'OwnerKey'>;
423
+ /**
424
+ * A guarded table an owner may remove a row from. `CadreInvite` is excluded by design — its
425
+ * rows are never deleted (`NoDelete`), and withdrawing one is a tombstone alone
426
+ * ({@link ControlDatabase.withdrawCadreInvite}).
427
+ */
428
+ type RemovableTable = Exclude<RevocableTable, 'CadreInvite'>;
305
429
 
306
430
  /**
307
431
  * One row incarnation's owner-signed removal, ready for {@link ControlDatabase.execGuardedRemoval}:
@@ -316,16 +440,37 @@ interface GuardedRemoval {
316
440
  readonly revocationSignature: string;
317
441
  }
318
442
 
443
+ /**
444
+ * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
445
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
446
+ * tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
447
+ * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
448
+ * `revocationDigest`.
449
+ */
450
+ function revocationTombstoneMessage(partyId: string, ref: { tableName: string; rowKey: string; stampId: string }): Uint8Array {
451
+ return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
452
+ }
453
+
454
+ /**
455
+ * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
456
+ * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
457
+ */
458
+ function ownerKeyAddMessage(partyId: string, key: string, stampId: string): Uint8Array {
459
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
460
+ }
461
+
319
462
  function signGuardedRemoval(
463
+ partyId: string,
320
464
  tableName: RemovableTable,
321
465
  rowKey: string,
322
466
  stampId: string,
323
467
  signMessage: (message: Uint8Array) => string
324
468
  ): GuardedRemoval {
469
+ const ref = { tableName, rowKey, stampId };
325
470
  return {
326
- ref: { tableName, rowKey, stampId },
327
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
328
- revocationSignature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId])),
471
+ ref,
472
+ signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
473
+ revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
329
474
  };
330
475
  }
331
476
 
@@ -333,7 +478,7 @@ function signGuardedRemoval(
333
478
  type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
334
479
 
335
480
  /** The control tables keyed by an `Id` column whose repeat insert a caller may classify. */
336
- type IdKeyedTable = StrandIdTable | Extract<RevocableTable, 'PendingJoin'>;
481
+ type IdKeyedTable = StrandIdTable | Extract<RevocableTable, 'JoinRequest'>;
337
482
 
338
483
  /**
339
484
  * The rejection a second insert of an already-seated id into `table` produces, as the
@@ -368,8 +513,8 @@ export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolea
368
513
  }
369
514
 
370
515
  /**
371
- * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
372
- * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
516
+ * Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
517
+ * `JoinRequest` row for this invitation? The caller adopts the existing request instead.
373
518
  * Text-matched, failing closed, like {@link isStrandIdConflict}.
374
519
  *
375
520
  * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
@@ -377,11 +522,11 @@ export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolea
377
522
  * until the next reap pass.
378
523
  */
379
524
  export function isPendingJoinConflict(error: unknown): boolean {
380
- return errorChainMatches(error, idConflictPattern('PendingJoin'));
525
+ return errorChainMatches(error, idConflictPattern('JoinRequest'));
381
526
  }
382
527
 
383
528
  /**
384
- * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
529
+ * The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
385
530
  * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
386
531
  * the bearer token itself.
387
532
  */
@@ -389,19 +534,23 @@ export function pendingJoinId(token: string): string {
389
534
  return digest([token], 'sha256', 'base64url') as string;
390
535
  }
391
536
 
537
+ /** A `JoinRequest` row as written: every column but the stamp, which each write mints. */
538
+ export type JoinRequestFields = Omit<JoinRequestRow, 'StampId'>;
539
+
540
+ /** A pending join as re-written: its request fields and its outcome, under a stamp the write mints. */
541
+ export type PendingJoinFields = Omit<PendingJoin, 'StampId'>;
542
+
392
543
  /**
393
- * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
394
- * incarnation the caller read: another write replaced or removed it first (a sibling machine
395
- * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
396
- * re-reads and decides again. `liveStampId` is null when the row is gone.
544
+ * A pending-join write found that the live join is not the one the caller read: another write
545
+ * recorded an outcome, asked again or dismissed it first. Nothing was written; the caller
546
+ * decides again from `live`, which is null when the request is gone.
397
547
  */
398
548
  export class PendingJoinChangedError extends Error {
399
549
  constructor(
400
550
  readonly id: string,
401
- readonly expectedStampId: string,
402
- readonly liveStampId: string | null,
551
+ readonly live: PendingJoin | null,
403
552
  ) {
404
- super(`PendingJoin ${id} changed: expected stamp ${expectedStampId}, found ${liveStampId ?? 'no row'}`);
553
+ super(`Pending join ${id} changed: ${live === null ? 'no request' : `request ${live.StampId}, ${live.outcome?.kind ?? 'pending'}`}`);
405
554
  this.name = 'PendingJoinChangedError';
406
555
  }
407
556
  }
@@ -437,6 +586,56 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
437
586
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
438
587
  }
439
588
 
589
+ /**
590
+ * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
591
+ * redemption reply, carries.
592
+ *
593
+ * Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
594
+ * Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
595
+ * key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
596
+ * `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
597
+ * or unparsable id yields a non-owner rather than failing the whole projection.
598
+ *
599
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
600
+ * the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
601
+ * peers first — not a trust decision, and the receiver re-derives real trust from its own
602
+ * anchor. So a polluted table costs at most a wasted dial, while anchoring here would
603
+ * silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
604
+ * anything the receiver TRUSTS, move it to the anchor.
605
+ */
606
+ export function projectSeedPeers(rows: readonly CadrePeerRow[], ownerKeys: ReadonlySet<string>): SeedPeer[] {
607
+ return rows.map(({ peerId, multiaddr }) => {
608
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
609
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
610
+ return {
611
+ peerId,
612
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
613
+ isOwner,
614
+ ...(isOwner ? { publicKey: pubKeyB64 } : {}),
615
+ };
616
+ });
617
+ }
618
+
619
+ /**
620
+ * The conditions a cadre invitation must still meet to be redeemed, short of its use count:
621
+ * not withdrawn, unexpired (`expiresAt <= now` is expired, as the redemption refuses it),
622
+ * and issued by a current owner. Shared by {@link ControlDatabase.hasLiveCadreInvite},
623
+ * {@link ControlDatabase.isCadreInviteLive} and {@link ControlDatabase.listCadreInviteStatuses}
624
+ * so they cannot drift from each other, or from `CadreInviteUsage.Authorized`.
625
+ */
626
+ function cadreInviteStillOpen(
627
+ row: { stampId: string; issuerKey: string; expiresAt: string | null },
628
+ withdrawn: ReadonlySet<string>,
629
+ owners: ReadonlySet<string>,
630
+ nowMs: number
631
+ ): boolean {
632
+ if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
633
+ return false;
634
+ }
635
+ const expiresAtMs = parseNullableStoredDatetimeMs(row.expiresAt);
636
+ return expiresAtMs === null || expiresAtMs > nowMs;
637
+ }
638
+
440
639
  /**
441
640
  * Guarded tables a node may reap locally once their tombstone has committed — the
442
641
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
@@ -445,24 +644,60 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
445
644
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
446
645
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
447
646
  * secret is the joined strand's shared read key, which its founding party and every member
448
- * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
647
+ * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
449
648
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
450
649
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
451
- * party-bricking hazard.
650
+ * party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
651
+ * withdrawal over a row that stays), so there is nothing to reap.
452
652
  */
453
- export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'PendingJoin'] as const satisfies readonly RevocableTable[];
653
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'] as const satisfies readonly RevocableTable[];
454
654
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
455
655
 
456
656
  /**
457
657
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
458
658
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
459
- * naming an excluded table (`Strand`, `OwnerKey`) is skipped silently — `Strand`
460
- * tombstones are the common case in a party that has unpublished a strand, so a log line
461
- * per skip would be per-pass noise.
659
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
660
+ * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
661
+ * log line per skip would be per-pass noise.
462
662
  */
463
663
  const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(REAPABLE_TABLES);
464
664
  const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
465
665
 
666
+ /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
667
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
668
+
669
+ /**
670
+ * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
671
+ * engine-canonical string it reads back as — the form the `'add'` signature covers — never
672
+ * parsed to ms here; `GrantsOwner` is the schema's 0/1 integer.
673
+ */
674
+ function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
675
+ return {
676
+ key: row.Key as string,
677
+ peerId: (row.PeerId as string | null) ?? null,
678
+ grantsOwner: Number(row.GrantsOwner) === 1,
679
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
680
+ totalUses: (row.TotalUses as number | null) ?? null,
681
+ issuerKey: row.IssuerKey as string,
682
+ issuerSig: row.IssuerSig as string,
683
+ stampId: row.StampId as string,
684
+ };
685
+ }
686
+
687
+ /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
688
+ function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageRow {
689
+ return {
690
+ usageStampId: row.UsageStampId as string,
691
+ inviteKey: row.InviteKey as string,
692
+ peerId: row.PeerId as string,
693
+ peerKey: row.PeerKey as string,
694
+ peerStampId: row.PeerStampId as string,
695
+ ownerStampId: (row.OwnerStampId as string | null) ?? null,
696
+ inviteSig: row.InviteSig as string,
697
+ peerSig: row.PeerSig as string,
698
+ };
699
+ }
700
+
466
701
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
467
702
  function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
468
703
  return {
@@ -473,55 +708,103 @@ function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
473
708
  };
474
709
  }
475
710
 
476
- /** Every `PendingJoin` column, in the schema's order. */
477
- const PENDING_JOIN_COLUMNS = 'Id, Invitation, Disclosure, RequestedAt, ExpiresAt, Outcome, OutcomeAt, StrandId, MembershipInvite, FailureCode, FailureReason, StampId';
711
+ const JOIN_REQUEST_INSERT_SQL = `
712
+ insert into CadreControl.JoinRequest (Id, Invitation, Disclosure, RequestedAt, ExpiresAt, StampId)
713
+ with context OwnerKey = ?, Signature = ?
714
+ values (?, ?, ?, ?, ?, ?)
715
+ `;
716
+
717
+ const JOIN_SUCCESS_INSERT_SQL = `
718
+ insert into CadreControl.JoinSuccess (RequestStampId, RecordedAt, StrandId, MembershipInvite)
719
+ with context OwnerKey = ?, Signature = ?
720
+ values (?, ?, ?, ?)
721
+ `;
478
722
 
479
- const PENDING_JOIN_INSERT_SQL = `
480
- insert into CadreControl.PendingJoin (${PENDING_JOIN_COLUMNS})
723
+ const JOIN_FAILURE_INSERT_SQL = `
724
+ insert into CadreControl.JoinFailure (RequestStampId, RecordedAt, Code, Reason)
481
725
  with context OwnerKey = ?, Signature = ?
482
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
726
+ values (?, ?, ?, ?)
483
727
  `;
484
728
 
485
- /** A `PendingJoin` row as read; the schema's `KnownOutcome` check backs the `Outcome` cast. */
486
- function pendingJoinRow(row: Record<string, SqlValue>): PendingJoinRow {
729
+ /**
730
+ * A request with its outcome in one statement: the outcome tables are keyed by the request's
731
+ * stamp, so the join finds exactly this incarnation's outcome.
732
+ */
733
+ const PENDING_JOIN_SELECT_SQL = `
734
+ select R.Id, R.Invitation, R.Disclosure, R.RequestedAt, R.ExpiresAt, R.StampId,
735
+ S.RecordedAt as JoinedAt, S.StrandId, S.MembershipInvite,
736
+ F.RecordedAt as FailedAt, F.Code, F.Reason
737
+ from CadreControl.JoinRequest R
738
+ left join CadreControl.JoinSuccess S on S.RequestStampId = R.StampId
739
+ left join CadreControl.JoinFailure F on F.RequestStampId = R.StampId
740
+ `;
741
+
742
+ /** A {@link PENDING_JOIN_SELECT_SQL} row as read. */
743
+ function pendingJoinOf(row: Record<string, SqlValue>): PendingJoin {
487
744
  return {
488
745
  Id: row.Id as string,
489
746
  Invitation: row.Invitation as string,
490
747
  Disclosure: row.Disclosure as string,
491
748
  RequestedAt: row.RequestedAt as number,
492
749
  ExpiresAt: row.ExpiresAt as number,
493
- Outcome: row.Outcome as PendingJoinRow['Outcome'],
494
- OutcomeAt: row.OutcomeAt as number | null,
495
- StrandId: row.StrandId as string | null,
496
- MembershipInvite: row.MembershipInvite as string | null,
497
- FailureCode: row.FailureCode as string | null,
498
- FailureReason: row.FailureReason as string | null,
499
750
  StampId: row.StampId as string,
751
+ outcome: joinOutcomeOf(row),
500
752
  };
501
753
  }
502
754
 
503
- /**
504
- * The row fields `PendingJoin.AuthorizedInsert` signs, in the schema's order: a null column
505
- * signs as `''` and an integer as its text (`cast(... as text)` on the SQL side).
506
- */
507
- function pendingJoinAddFields(row: PendingJoinRow): string[] {
508
- return [
509
- row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt),
510
- row.Outcome ?? '', row.OutcomeAt === null ? '' : String(row.OutcomeAt), row.StrandId ?? '',
511
- row.MembershipInvite ?? '', row.FailureCode ?? '', row.FailureReason ?? '', row.StampId,
512
- ];
755
+ /** The outcome half of a {@link PENDING_JOIN_SELECT_SQL} row; a request never has both (`JoinSuccess.NoFailure`, `JoinFailure.NotJoined`). */
756
+ function joinOutcomeOf(row: Record<string, SqlValue>): JoinOutcome | null {
757
+ if (typeof row.JoinedAt === 'number') {
758
+ return { kind: 'joined', RecordedAt: row.JoinedAt, StrandId: row.StrandId as string, MembershipInvite: row.MembershipInvite as string | null };
759
+ }
760
+ if (typeof row.FailedAt === 'number') {
761
+ return { kind: 'failed', RecordedAt: row.FailedAt, Code: row.Code as string, Reason: row.Reason as string };
762
+ }
763
+ return null;
513
764
  }
514
765
 
515
- /** The values of {@link PENDING_JOIN_INSERT_SQL}'s columns, in {@link PENDING_JOIN_COLUMNS} order. */
516
- function pendingJoinValues(row: PendingJoinRow): SqlValue[] {
517
- return [
518
- row.Id, row.Invitation, row.Disclosure, row.RequestedAt, row.ExpiresAt, row.Outcome, row.OutcomeAt,
519
- row.StrandId, row.MembershipInvite, row.FailureCode, row.FailureReason, row.StampId,
520
- ];
766
+ /** The fields `JoinRequest.AuthorizedInsert` signs, in the schema's order; integers sign as their text. */
767
+ function joinRequestAddFields(row: JoinRequestRow): string[] {
768
+ return [row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt), row.StampId];
769
+ }
770
+
771
+ /** A statement with its parameters, signed and ready to run. */
772
+ interface SignedStatement {
773
+ readonly sql: string;
774
+ readonly params: SqlValue[];
521
775
  }
522
776
 
523
777
  /**
524
- * Notified after a `CadreControl.CadrePeer` row write has COMMITTED.
778
+ * The owner-signed insert of `outcome` for the request incarnation `requestStampId`: the
779
+ * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
780
+ * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
781
+ */
782
+ function signJoinOutcomeInsert(
783
+ partyId: string,
784
+ requestStampId: string,
785
+ outcome: JoinOutcome,
786
+ ownerKey: string,
787
+ signMessage: (message: Uint8Array) => string
788
+ ): SignedStatement {
789
+ const at = String(outcome.RecordedAt);
790
+ const { table, sql, fields, values } = outcome.kind === 'joined'
791
+ ? {
792
+ table: 'JoinSuccess' as const, sql: JOIN_SUCCESS_INSERT_SQL,
793
+ fields: [requestStampId, at, outcome.StrandId, outcome.MembershipInvite ?? ''],
794
+ values: [requestStampId, outcome.RecordedAt, outcome.StrandId, outcome.MembershipInvite],
795
+ }
796
+ : {
797
+ table: 'JoinFailure' as const, sql: JOIN_FAILURE_INSERT_SQL,
798
+ fields: [requestStampId, at, outcome.Code, outcome.Reason],
799
+ values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
800
+ };
801
+ const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
802
+ return { sql, params: [ownerKey, signature, ...values] };
803
+ }
804
+
805
+ /**
806
+ * Notified after a membership write (a `CadreControl.CadrePeer` or `OwnerKey` row, see
807
+ * {@link ControlDatabase.mutateCadrePeer}) has COMMITTED.
525
808
  *
526
809
  * The one hook the party-membership snapshot a node admits control-DB traffic
527
810
  * against ({@link CadreNode.refreshMembershipGate}) hangs off, so that snapshot
@@ -532,20 +815,21 @@ function pendingJoinValues(row: PendingJoinRow): SqlValue[] {
532
815
  export type MembershipChangeListener = (reason: string) => Promise<void>;
533
816
 
534
817
  /**
535
- * Identity of the tombstone a committed guarded delete wrote — a
536
- * {@link RevocationRow} minus its `ReissuedAt` counter, which a fresh tombstone
537
- * always seats at 0 (`FreshTombstone`) and so carries no information.
818
+ * Identity of the tombstone a committed guarded delete wrote — the triple a
819
+ * {@link RevocationRow} is keyed and signed over. The counter is left out because a
820
+ * fresh tombstone always seats it at 0 (`FreshTombstone`), and the signer pair because
821
+ * the writer that files the row supplies it.
538
822
  */
539
- export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
823
+ export type RevokedRowRef = Pick<RevocationRow, 'tableName' | 'rowKey' | 'stampId'>;
540
824
 
541
825
  /**
542
826
  * Notified after a guarded-table delete — and the `CadreControl.Revocation`
543
827
  * tombstone it writes in the same transaction — has COMMITTED.
544
828
  *
545
829
  * The seam the write-while-alone re-replication queue hangs off
546
- * ({@link CadreNode.noteGuardedDelete}): one listener covers all four guarded
547
- * tables (`CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`) because every
548
- * owner delete funnels through {@link deleteGuardedRow}. Synchronous — the
830
+ * ({@link CadreNode.noteGuardedDelete}): one listener covers every guarded table
831
+ * because every owner delete funnels through {@link deleteGuardedRow} (or the
832
+ * two-table `deleteStrandAndPartyKey`). Synchronous — the
549
833
  * handler only records the tombstone's identity; it must not throw (the notifier
550
834
  * swallows and logs anyway — a committed delete never fails because bookkeeping did).
551
835
  */
@@ -566,7 +850,12 @@ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
566
850
  export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
567
851
 
568
852
  export interface ControlDatabaseConfig {
569
- /** Party ID for the control network */
853
+ /**
854
+ * Party ID for the control network. Also the party every owner and peer signature over a
855
+ * control row binds ({@link buildAuthorizationMessage}), read on the SQL side through
856
+ * `party_id()` ({@link registerPartyIdFunction}). Must be non-empty: parties with an empty
857
+ * id would share approvals.
858
+ */
570
859
  partyId: string;
571
860
  /**
572
861
  * Optional path to the control schema file.
@@ -617,6 +906,9 @@ export class ControlDatabase {
617
906
  private controlReadRetryPacing: ControlReadRetryOptions = {};
618
907
 
619
908
  constructor(config: ControlDatabaseConfig) {
909
+ if (typeof config.partyId !== 'string' || config.partyId.length === 0) {
910
+ throw new Error('ControlDatabase requires a non-empty partyId: every control approval is bound to it');
911
+ }
620
912
  this.config = config;
621
913
  }
622
914
 
@@ -659,6 +951,9 @@ export class ControlDatabase {
659
951
  }
660
952
  timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
661
953
 
954
+ // Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
955
+ registerPartyIdFunction(this.db, this.config.partyId);
956
+
662
957
  this.collectionFactory = pluginResult.collectionFactory;
663
958
 
664
959
  // Inject the libp2p node into the collection factory
@@ -1050,28 +1345,33 @@ export class ControlDatabase {
1050
1345
  }
1051
1346
 
1052
1347
  /**
1053
- * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
1054
- * pending or finished.
1348
+ * Every join this party asked for and has not dismissed, pending or finished: each
1349
+ * `CadreControl.JoinRequest` row with its `JoinSuccess` / `JoinFailure` row.
1055
1350
  *
1056
- * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
1057
- * drops them: such a row is either a replaced or dismissed incarnation this node still
1058
- * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
1059
- * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
1351
+ * A request whose `StampId` is retired in `Revocation` is dropped, as {@link queryCadrePeers}
1352
+ * drops such rows: it is a re-asked or dismissed incarnation this node still holds until the
1353
+ * reap sweep removes it, or one re-seated by a replayed approval on a node that had not yet
1354
+ * seen the tombstone. Either way it must not bring a dismissed join back.
1060
1355
  */
1061
- async queryPendingJoins(): Promise<PendingJoinRow[]> {
1356
+ async queryPendingJoins(): Promise<PendingJoin[]> {
1062
1357
  this.ensureInitialized();
1063
- const revoked = await this.queryRevokedStamps('PendingJoin');
1064
- const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin`, undefined, 'pending-joins');
1065
- return rows.map(pendingJoinRow).filter(row => !revoked.has(row.StampId));
1358
+ const revoked = await this.queryRevokedStamps('JoinRequest');
1359
+ const rows = await this.readRows(PENDING_JOIN_SELECT_SQL, undefined, 'pending-joins');
1360
+ return rows.map(pendingJoinOf).filter(join => !revoked.has(join.StampId));
1066
1361
  }
1067
1362
 
1068
- /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
1069
- async queryPendingJoin(id: string): Promise<PendingJoinRow | null> {
1363
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the join with this id, or null. */
1364
+ async queryPendingJoin(id: string): Promise<PendingJoin | null> {
1070
1365
  this.ensureInitialized();
1071
- const revoked = await this.queryRevokedStamps('PendingJoin');
1072
- const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin where Id = ?`, [id], 'pending-join');
1073
- const row = rows.length === 0 ? null : pendingJoinRow(rows[0]!);
1074
- return row === null || revoked.has(row.StampId) ? null : row;
1366
+ const revoked = await this.queryRevokedStamps('JoinRequest');
1367
+ const join = await this.readPendingJoin(id, true);
1368
+ return join === null || revoked.has(join.StampId) ? null : join;
1369
+ }
1370
+
1371
+ /** The raw read behind {@link queryPendingJoin}, with no retired-stamp filter. `retry: false` inside a locked write body. */
1372
+ private async readPendingJoin(id: string, retry: boolean): Promise<PendingJoin | null> {
1373
+ const rows = await this.readRows(`${PENDING_JOIN_SELECT_SQL} where R.Id = ?`, [id], 'pending-join', retry);
1374
+ return rows.length === 0 ? null : pendingJoinOf(rows[0]!);
1075
1375
  }
1076
1376
 
1077
1377
  /**
@@ -1107,6 +1407,9 @@ export class ControlDatabase {
1107
1407
  /**
1108
1408
  * Check whether any owner key exists in the control database.
1109
1409
  * Used to decide whether a fresh-party genesis insert is required.
1410
+ *
1411
+ * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1412
+ * insert is refused by the schema whenever any physical row exists, so this must see one.
1110
1413
  */
1111
1414
  async hasOwnerKey(): Promise<boolean> {
1112
1415
  this.ensureInitialized();
@@ -1139,14 +1442,66 @@ export class ControlDatabase {
1139
1442
  * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
1140
1443
  * also the owner-identity source for `queryPeers`, decoupling owner
1141
1444
  * status from the libp2p transport peer ID.
1445
+ *
1446
+ * Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded, as
1447
+ * {@link queryCadrePeers} excludes them: `OwnerKey` is never reaped (`MinOneOwner` makes
1448
+ * an automated reap able to empty the table), so a node that received the tombstone
1449
+ * without the delete (a removal committed while the remover was alone, re-issued on growth)
1450
+ * keeps the removed owner's physical row next to it. Without the filter a removed owner
1451
+ * would still count as an owner in every membership snapshot and seed-trust read, still
1452
+ * read as the issuer of a live invitation and still be preferred as a dial target. The
1453
+ * write side filters for itself: every owner lookup in the schema disqualifies a row whose
1454
+ * stamp is retired (`schemas/control.qsql` header), so a removed owner cannot sign here
1455
+ * once its tombstone has arrived, row or no row.
1456
+ *
1457
+ * `retry: false` from a caller that reads under the write lock, for the reason on
1458
+ * {@link queryCadrePeers}.
1142
1459
  */
1143
- async getOwnerKeys(): Promise<Set<string>> {
1460
+ async getOwnerKeys(retry = true): Promise<Set<string>> {
1461
+ return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
1462
+ }
1463
+
1464
+ /**
1465
+ * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1466
+ * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1467
+ * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1468
+ * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1469
+ * reads through this. A row with no stamp (the schema permits it; no writer produces one)
1470
+ * is dropped too: no proof can bind to it, so no reader could ever derive it.
1471
+ *
1472
+ * `retry: false` from the membership-gate refresh, which runs as the control database's
1473
+ * membership listener with that database's write lock held — the reason on
1474
+ * {@link queryCadrePeers}; both reads here take the flag.
1475
+ *
1476
+ * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1477
+ * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1478
+ * signers); omitted, they are read here. Before the ledger marker is filed every
1479
+ * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1480
+ * membership refresh at one such read for the anchor.
1481
+ */
1482
+ async queryOwnerKeyRows(retry = true, retired?: ReadonlySet<string>): Promise<OwnerKeyRow[]> {
1144
1483
  this.ensureInitialized();
1145
- const keys = new Set<string>();
1146
- for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
1147
- keys.add(row.Key as string);
1484
+ const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1485
+ const rows: OwnerKeyRow[] = [];
1486
+ const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1487
+ for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1488
+ const stampId = (row.StampId as string | null) ?? null;
1489
+ if (stampId === null) {
1490
+ log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
1491
+ continue;
1492
+ }
1493
+ if (revoked.has(stampId)) {
1494
+ continue;
1495
+ }
1496
+ rows.push({
1497
+ key: row.Key as string,
1498
+ stampId,
1499
+ vouchOwner: (row.VouchOwner as string | null) ?? null,
1500
+ vouchSig: (row.VouchSig as string | null) ?? null,
1501
+ vouchUsage: (row.VouchUsage as string | null) ?? null,
1502
+ });
1148
1503
  }
1149
- return keys;
1504
+ return rows;
1150
1505
  }
1151
1506
 
1152
1507
  /**
@@ -1189,7 +1544,7 @@ export class ControlDatabase {
1189
1544
  this.ensureInitialized();
1190
1545
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1191
1546
  const rows: CadrePeerRow[] = [];
1192
- for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
1547
+ for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
1193
1548
  const stampId = (row.StampId as string | null) ?? null;
1194
1549
  if (stampId !== null && revoked.has(stampId)) {
1195
1550
  continue;
@@ -1200,11 +1555,26 @@ export class ControlDatabase {
1200
1555
  stampId,
1201
1556
  vouchOwner: (row.VouchOwner as string | null) ?? null,
1202
1557
  vouchSig: (row.VouchSig as string | null) ?? null,
1558
+ vouchUsage: (row.VouchUsage as string | null) ?? null,
1203
1559
  });
1204
1560
  }
1205
1561
  return rows;
1206
1562
  }
1207
1563
 
1564
+ /**
1565
+ * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
1566
+ * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
1567
+ * the replicated `OwnerKey` table ({@link projectSeedPeers}).
1568
+ *
1569
+ * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1570
+ * member's addresses are never packed into a seed or handed to a redeeming device as a
1571
+ * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1572
+ * its exits.
1573
+ */
1574
+ async querySeedPeers(): Promise<SeedPeer[]> {
1575
+ return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
1576
+ }
1577
+
1208
1578
  /**
1209
1579
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
1210
1580
  * set a `ValidationUrl` redemption's approval signature must be signed by (see
@@ -1402,22 +1772,26 @@ export class ControlDatabase {
1402
1772
  }
1403
1773
 
1404
1774
  /**
1405
- * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
1406
- * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
1407
- * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
1408
- * it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
1409
- * the composite-primary-key point-lookup hazard (see the statement comment in
1775
+ * Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
1776
+ * `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
1777
+ * sweep, which enumerates what this node holds before {@link reissueRevocations}
1778
+ * re-broadcasts it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
1779
+ * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1410
1780
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1411
1781
  *
1412
1782
  * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1413
1783
  * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1414
1784
  * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1415
1785
  * admits no other row under `'Revocation'`.
1786
+ *
1787
+ * `retry: false` from a caller that reads under the write lock, for the reason on
1788
+ * {@link queryCadrePeers}.
1416
1789
  */
1417
- async queryRevocations(): Promise<RevocationRow[]> {
1790
+ async queryRevocations(retry = true): Promise<RevocationRow[]> {
1418
1791
  this.ensureInitialized();
1419
1792
  const rows: RevocationRow[] = [];
1420
- for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
1793
+ const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
1794
+ for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
1421
1795
  if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1422
1796
  continue;
1423
1797
  }
@@ -1426,6 +1800,8 @@ export class ControlDatabase {
1426
1800
  rowKey: row.RowKey as string,
1427
1801
  stampId: row.StampId as string,
1428
1802
  reissuedAt: (row.ReissuedAt as number | null) ?? 0,
1803
+ signerKey: row.SignerKey as string,
1804
+ signerSig: row.SignerSig as string,
1429
1805
  });
1430
1806
  }
1431
1807
  return rows;
@@ -1571,12 +1947,12 @@ export class ControlDatabase {
1571
1947
 
1572
1948
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
1573
1949
  // committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
1574
- // signature is needed. Every other branch of `OwnerKey.Authorized` requires a signature
1575
- // from a PRE-EXISTING owner, so this method only ever succeeds on a fresh party; seating
1576
- // a second owner (or removing one) has no writer here and must sign the digests
1577
- // documented on the schema's `Authorized` constraint. We still persist a fresh, unique
1578
- // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
1579
- // the StampId is a real column value, not the optimystic `StampId()` SQL function.
1950
+ // signature is needed. Every other branch of `OwnerKey.Authorized` requires a PRE-EXISTING,
1951
+ // unretired owner, so this method only ever succeeds on a fresh party; a second
1952
+ // owner is seated by {@link insertOwnerKeyVouched} and removed by {@link deleteOwnerKey}.
1953
+ // We still persist a fresh, unique StampId in the row's own column to satisfy the
1954
+ // not-null/unique anti-replay constraint — the StampId is a real column value, not the
1955
+ // optimystic `StampId()` SQL function.
1580
1956
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1581
1957
  await this.execWrite(`
1582
1958
  insert into CadreControl.OwnerKey (Key, StampId)
@@ -1586,6 +1962,100 @@ export class ControlDatabase {
1586
1962
  log('Owner key inserted');
1587
1963
  }
1588
1964
 
1965
+ /**
1966
+ * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
1967
+ * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
1968
+ * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
1969
+ * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
1970
+ * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
1971
+ *
1972
+ * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
1973
+ * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
1974
+ * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
1975
+ *
1976
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
1977
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
1978
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
1979
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
1980
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
1981
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
1982
+ *
1983
+ * @returns `true` when this call seated the row, `false` when `key` is already a live owner
1984
+ * here (nothing written).
1985
+ * @throws when this node holds `key`'s row under a retired stamp (above), or when the
1986
+ * schema refuses the signer.
1987
+ */
1988
+ async insertOwnerKeyVouched(
1989
+ key: string,
1990
+ ownerKey: string,
1991
+ signMessage: (message: Uint8Array) => string
1992
+ ): Promise<boolean> {
1993
+ this.ensureInitialized();
1994
+ return await this.mutateCadrePeer('owner-key-add', async () => {
1995
+ // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1996
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
1997
+ if (heldStamp !== null) {
1998
+ if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
1999
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
2000
+ return false;
2001
+ }
2002
+ throw new Error(
2003
+ `Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
2004
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it'
2005
+ );
2006
+ }
2007
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2008
+ const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
2009
+ // Bare `exec`: already inside the write lock, which is NOT re-entrant.
2010
+ await this.db!.exec(`
2011
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
2012
+ with context OwnerKey = ?, Signature = ?
2013
+ values (?, ?, ?, ?)
2014
+ `, [ownerKey, signature, key, stampId, ownerKey, signature]);
2015
+ log('Owner key %s added (vouched by %s)', key, ownerKey);
2016
+ return true;
2017
+ });
2018
+ }
2019
+
2020
+ /**
2021
+ * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
2022
+ * stamp, notifying the membership listener once it has committed. The remove branch of
2023
+ * `OwnerKey.Authorized` requires a signer other than the removed key, and `MinOneOwner`
2024
+ * keeps the table from emptying; both are refused by name here first, read from this
2025
+ * node's live owners ({@link getOwnerKeys}), so a caller gets a clear error rather than a
2026
+ * constraint failure. The schema stays the authority: a concurrent removal elsewhere can
2027
+ * make the local count wrong in either direction, and then `MinOneOwner` or the tombstone's
2028
+ * primary key decides (see the NOTE on {@link deleteGuardedRow}).
2029
+ *
2030
+ * Through {@link mutateCadrePeer}, the membership hub, which notifies whenever the body
2031
+ * resolves, the absent-key answer included.
2032
+ *
2033
+ * @returns `true` when the row was removed, `false` when `key` is not a live owner here
2034
+ * (nothing written, no tombstone).
2035
+ * @throws when `key` is `ownerKey`, or is the only live owner.
2036
+ */
2037
+ async deleteOwnerKey(
2038
+ key: string,
2039
+ ownerKey: string,
2040
+ signMessage: (message: Uint8Array) => string
2041
+ ): Promise<boolean> {
2042
+ if (key === ownerKey) {
2043
+ throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
2044
+ }
2045
+ return await this.mutateCadrePeer('owner-key-remove', async () => {
2046
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2047
+ const owners = await this.getOwnerKeys(false);
2048
+ if (!owners.has(key)) {
2049
+ log('deleteOwnerKey: %s is not a live owner here (already absent)', key);
2050
+ return false;
2051
+ }
2052
+ if (owners.size <= 1) {
2053
+ throw new Error(`Cannot remove owner key ${key}: cannot remove the last owner`);
2054
+ }
2055
+ return await this.deleteGuardedRow('OwnerKey', key, ownerKey, signMessage);
2056
+ });
2057
+ }
2058
+
1589
2059
  /**
1590
2060
  * Insert a strand into the control database using an owner signature.
1591
2061
  *
@@ -1623,7 +2093,7 @@ export class ControlDatabase {
1623
2093
  // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
1624
2094
  // deliberately NOT in the digest — the schema binds it by equality to the
1625
2095
  // verified context.OwnerKey instead (see the constraint's comment).
1626
- const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
2096
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
1627
2097
  const signature = signMessage(message);
1628
2098
 
1629
2099
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
@@ -1698,10 +2168,10 @@ export class ControlDatabase {
1698
2168
  }
1699
2169
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1700
2170
 
1701
- const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
2171
+ const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
1702
2172
  const partyKeyRemoval = partyKeyStamp === null
1703
2173
  ? null
1704
- : signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
2174
+ : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
1705
2175
 
1706
2176
  await this.inTransaction('delete Strand', async () => {
1707
2177
  await this.execGuardedRemoval(strandRemoval, ownerKey);
@@ -1749,7 +2219,7 @@ export class ControlDatabase {
1749
2219
 
1750
2220
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
1751
2221
  // Id, PrivateKey, StampId.
1752
- const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
2222
+ const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
1753
2223
  const signature = signMessage(message);
1754
2224
 
1755
2225
  await this.execWrite(`
@@ -1805,7 +2275,7 @@ export class ControlDatabase {
1805
2275
 
1806
2276
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
1807
2277
  // Id, Type, MemberPrivateKey ('' when null), StampId.
1808
- const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
2278
+ const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
1809
2279
  const signature = signMessage(message);
1810
2280
 
1811
2281
  await this.execWrite(`
@@ -1832,97 +2302,155 @@ export class ControlDatabase {
1832
2302
  }
1833
2303
 
1834
2304
  /**
1835
- * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
1836
- * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
1837
- * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
2305
+ * Record, party-wide, a join this party asked for: a `JoinRequest` row under a fresh stamp,
2306
+ * owner-signed over every column. Returns the pending join as written.
1838
2307
  *
1839
- * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
1840
- * this invitation; {@link isPendingJoinConflict} identifies that case.
2308
+ * Fails with a `JoinRequest.Id` uniqueness violation when the party already holds a request
2309
+ * for this invitation; {@link isPendingJoinConflict} identifies that case.
1841
2310
  */
1842
- async insertPendingJoin(
1843
- row: Omit<PendingJoinRow, 'StampId'>,
2311
+ async insertJoinRequest(
2312
+ request: JoinRequestFields,
1844
2313
  ownerKey: string,
1845
2314
  signMessage: (message: Uint8Array) => string
1846
- ): Promise<PendingJoinRow> {
2315
+ ): Promise<PendingJoin> {
1847
2316
  this.ensureInitialized();
1848
- log('Inserting pending join: %s (outcome: %s)', row.Id, row.Outcome);
1849
- const { written, params } = this.signPendingJoinInsert(row, ownerKey, signMessage);
1850
- await this.execWrite(PENDING_JOIN_INSERT_SQL, params, 'pending-join-insert');
1851
- log('Pending join inserted: %s', row.Id);
1852
- return written;
2317
+ const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2318
+ await this.execWrite(insert.sql, insert.params, 'join-request-insert');
2319
+ log('Join request inserted: %s', request.Id);
2320
+ return { ...written, outcome: null };
1853
2321
  }
1854
2322
 
1855
2323
  /**
1856
- * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
1857
- * an outcome is written, since the table refuses updates. Returns the row as written.
1858
- *
1859
- * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
1860
- * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
1861
- * between. The live row must still be the incarnation the caller read: when its `StampId` is
1862
- * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
1863
- * writes nothing. That check is local; a sibling machine's replacement or removal that lands
1864
- * between it and the commit fails the transaction instead (its tombstone for the same stamp,
1865
- * or its successor row, collides on a primary key), and the caller re-reads either way.
2324
+ * Record the outcome of the pending join `expected`: its owner-signed `JoinSuccess` /
2325
+ * `JoinFailure` row, keyed by the request's stamp. A join recorded over a failure deletes the
2326
+ * failure row in the same transaction (the schema authorizes that delete by the join, and
2327
+ * leaves the one-outcome rule in that direction to this writer). Returns the pending join as
2328
+ * written.
2329
+ *
2330
+ * The live join must still be what the caller read: the same request incarnation with the
2331
+ * same outcome kind. Otherwise this throws {@link PendingJoinChangedError} and writes nothing.
2332
+ * That check is local; a sibling machine's write landing between it and the commit fails the
2333
+ * transaction instead (its outcome row collides on the primary key, or its tombstone retires
2334
+ * the stamp), and the caller re-reads either way.
2335
+ */
2336
+ async recordJoinOutcome(
2337
+ expected: PendingJoin,
2338
+ outcome: JoinOutcome,
2339
+ ownerKey: string,
2340
+ signMessage: (message: Uint8Array) => string
2341
+ ): Promise<PendingJoin> {
2342
+ this.ensureInitialized();
2343
+ const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2344
+ return this.lockedWithRetry(async () => {
2345
+ const live = await this.readPendingJoin(expected.Id, false);
2346
+ if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
2347
+ throw new PendingJoinChangedError(expected.Id, live);
2348
+ }
2349
+ await this.inTransaction('record join outcome', async () => {
2350
+ if (live.outcome?.kind === 'failed') {
2351
+ await this.deleteJoinOutcome('JoinFailure', expected.StampId);
2352
+ }
2353
+ await this.db!.exec(insert.sql, insert.params);
2354
+ });
2355
+ log('Join outcome recorded: %s (%s)', expected.Id, outcome.kind);
2356
+ return { ...live, outcome };
2357
+ }, {}, 'join-outcome-record');
2358
+ }
2359
+
2360
+ /**
2361
+ * Re-write the pending join `next` (request and outcome) under a fresh stamp, retiring the
2362
+ * live request incarnation `expectedStampId`: how a finished request is asked for again, and
2363
+ * how a join written while this machine was alone is re-broadcast. One locked transaction
2364
+ * deletes the outcome row and the request (owner-signed `'remove'` digest, with its
2365
+ * `Revocation` tombstone) and inserts the successors, so no reader ever sees the id missing in
2366
+ * between. Throws {@link PendingJoinChangedError} and writes nothing when the live request is
2367
+ * not `expectedStampId`.
1866
2368
  *
1867
2369
  * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
1868
2370
  * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
1869
- * that committed but reported failure throws {@link PendingJoinChangedError} whose
1870
- * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
2371
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose live join
2372
+ * carries the returned stamp, so a caller that re-reads finds its write.
1871
2373
  */
1872
- async replacePendingJoin(
2374
+ async rewritePendingJoin(
1873
2375
  expectedStampId: string,
1874
- next: Omit<PendingJoinRow, 'StampId'>,
2376
+ next: PendingJoinFields,
1875
2377
  ownerKey: string,
1876
2378
  signMessage: (message: Uint8Array) => string
1877
- ): Promise<PendingJoinRow> {
2379
+ ): Promise<PendingJoin> {
1878
2380
  this.ensureInitialized();
1879
- const id = next.Id;
1880
- const removal = signGuardedRemoval('PendingJoin', id, expectedStampId, signMessage);
1881
- const { written, params } = this.signPendingJoinInsert(next, ownerKey, signMessage);
2381
+ const { outcome, ...request } = next;
2382
+ const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2383
+ const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2384
+ const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
1882
2385
 
1883
2386
  return this.lockedWithRetry(async () => {
1884
2387
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1885
- const liveStampId = await this.queryStampId('PendingJoin', id, false);
2388
+ const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
1886
2389
  if (liveStampId !== expectedStampId) {
1887
- throw new PendingJoinChangedError(id, expectedStampId, liveStampId);
2390
+ throw new PendingJoinChangedError(next.Id, await this.readPendingJoin(next.Id, false));
1888
2391
  }
1889
- await this.inTransaction('replace PendingJoin', async () => {
2392
+ await this.inTransaction('rewrite pending join', async () => {
2393
+ await this.deleteJoinOutcomes(expectedStampId);
1890
2394
  await this.execGuardedRemoval(removal, ownerKey);
1891
- await this.db!.exec(PENDING_JOIN_INSERT_SQL, params);
2395
+ await this.db!.exec(insert.sql, insert.params);
2396
+ if (outcomeInsert !== null) {
2397
+ await this.db!.exec(outcomeInsert.sql, outcomeInsert.params);
2398
+ }
1892
2399
  });
1893
- log('Pending join replaced: %s (outcome: %s; stamp retired)', id, next.Outcome);
2400
+ log('Pending join rewritten: %s (%s; stamp retired)', next.Id, outcome?.kind ?? 'pending');
1894
2401
  this.notifyGuardedDelete(removal.ref);
1895
- return written;
1896
- }, {}, 'pending-join-replace');
2402
+ return { ...written, outcome };
2403
+ }, {}, 'pending-join-rewrite');
1897
2404
  }
1898
2405
 
1899
2406
  /**
1900
- * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
1901
- * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
1902
- * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
1903
- * `true` when a row was actually removed.
2407
+ * Owner-signed removal of one pending join, as {@link deleteJoinedStrand} removes its row:
2408
+ * `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same transaction,
2409
+ * and the request's outcome row with it. A no-op (no throw, no tombstone) when there is no
2410
+ * request — `false` then, `true` when one was actually removed.
1904
2411
  */
1905
2412
  deletePendingJoin(
1906
2413
  id: string,
1907
2414
  ownerKey: string,
1908
2415
  signMessage: (message: Uint8Array) => string
1909
2416
  ): Promise<boolean> {
1910
- return this.lockedWithRetry(() => this.deleteGuardedRow('PendingJoin', id, ownerKey, signMessage), {}, 'pending-join-delete');
2417
+ return this.lockedWithRetry(
2418
+ () => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)),
2419
+ {}, 'pending-join-delete');
1911
2420
  }
1912
2421
 
1913
2422
  /**
1914
- * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
1915
- * verifies, returning the row as it will be written and the parameters of
1916
- * {@link PENDING_JOIN_INSERT_SQL}.
2423
+ * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
2424
+ * verifies, returning the row as it will be written and its insert.
1917
2425
  */
1918
- private signPendingJoinInsert(
1919
- row: Omit<PendingJoinRow, 'StampId'>,
2426
+ private signJoinRequestInsert(
2427
+ request: JoinRequestFields,
1920
2428
  ownerKey: string,
1921
2429
  signMessage: (message: Uint8Array) => string
1922
- ): { written: PendingJoinRow; params: SqlValue[] } {
1923
- const written: PendingJoinRow = { ...row, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
1924
- const signature = signMessage(buildAuthorizationMessage('CadreControl.PendingJoin', 'add', pendingJoinAddFields(written)));
1925
- return { written, params: [ownerKey, signature, ...pendingJoinValues(written)] };
2430
+ ): { written: JoinRequestRow; insert: SignedStatement } {
2431
+ const written: JoinRequestRow = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
2432
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2433
+ const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
2434
+ return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
2435
+ }
2436
+
2437
+ /**
2438
+ * Remove the outcome row of the request incarnation `requestStampId`, whichever table holds
2439
+ * it. Unsigned: the schema authorizes the delete by the tombstone retiring that stamp, filed
2440
+ * in the same transaction or already committed (a reap).
2441
+ */
2442
+ private async deleteJoinOutcomes(requestStampId: string): Promise<void> {
2443
+ await this.deleteJoinOutcome('JoinSuccess', requestStampId);
2444
+ await this.deleteJoinOutcome('JoinFailure', requestStampId);
2445
+ }
2446
+
2447
+ /** One unsigned outcome delete (see {@link deleteJoinOutcomes}; a failure's is also authorized by the join superseding it). */
2448
+ private async deleteJoinOutcome(table: 'JoinSuccess' | 'JoinFailure', requestStampId: string): Promise<void> {
2449
+ await this.db!.exec(`
2450
+ delete from CadreControl.${table}
2451
+ with context OwnerKey = null, Signature = null
2452
+ where RequestStampId = ?
2453
+ `, [requestStampId]);
1926
2454
  }
1927
2455
 
1928
2456
  /**
@@ -1950,7 +2478,7 @@ export class ControlDatabase {
1950
2478
  const stampId = generateStampId(peerId);
1951
2479
 
1952
2480
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
1953
- const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
2481
+ const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
1954
2482
  const signature = signMessage(message);
1955
2483
 
1956
2484
  await this.execWrite(`
@@ -2039,7 +2567,7 @@ export class ControlDatabase {
2039
2567
  // row it vouches.
2040
2568
  const stampId = generateStampId(row.peerId);
2041
2569
  const signature = signMessage(
2042
- buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId])
2570
+ buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId])
2043
2571
  );
2044
2572
  return await this.mutateCadrePeer('peer-insert', async () => {
2045
2573
  // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
@@ -2094,6 +2622,11 @@ export class ControlDatabase {
2094
2622
  * change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
2095
2623
  * beats a per-method exception the next reader has to relearn.
2096
2624
  *
2625
+ * Also clears `VouchUsage`: a row carries exactly one proof (the owner branch of
2626
+ * `AuthorizedUpdate` requires it null), so re-vouching an invitation-admitted row moves it
2627
+ * onto the signature and its invitation is no longer consulted for it — the way an owner
2628
+ * retires a device's dependency on an invitation it may later withdraw.
2629
+ *
2097
2630
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
2098
2631
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
2099
2632
  * reader's node-local anchor. Benign today because the only caller — the write-while-alone
@@ -2123,14 +2656,14 @@ export class ControlDatabase {
2123
2656
  return false;
2124
2657
  }
2125
2658
  const signature = signMessage(
2126
- buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId])
2659
+ buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId])
2127
2660
  );
2128
2661
  await this.mutateCadrePeer('peer-reauthorize', async () => {
2129
2662
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2130
2663
  await this.db!.exec(`
2131
2664
  update CadreControl.CadrePeer
2132
2665
  with context OwnerKey = ?, Signature = ?
2133
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
2666
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2134
2667
  where PeerId = ?
2135
2668
  `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
2136
2669
  });
@@ -2191,15 +2724,9 @@ export class ControlDatabase {
2191
2724
  * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
2192
2725
  * stamp, in ONE transaction. The single body behind EVERY guarded delete —
2193
2726
  * {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
2194
- * {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
2195
- * of those is a thin named wrapper, so callers never pass a table name and this
2196
- * generic shape stays off the public surface.
2197
- *
2198
- * `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
2199
- * production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
2200
- * requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
2201
- * it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
2202
- * needs no change.
2727
+ * {@link deleteDeviceToken}, {@link deleteOwnerKey}; see any of them for the per-table
2728
+ * security rationale. Each of those is a thin named wrapper, so callers never pass a
2729
+ * table name and this generic shape stays off the public surface.
2203
2730
  *
2204
2731
  * The row's CURRENT stamp is read first and signed over, so the remove digest binds to
2205
2732
  * this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
@@ -2207,6 +2734,9 @@ export class ControlDatabase {
2207
2734
  * second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
2208
2735
  * which must not fire for a no-op).
2209
2736
  *
2737
+ * `alongside`, when given, runs inside the transaction before the removal, for a table whose
2738
+ * row has dependants the schema requires removed with it ({@link deletePendingJoin}).
2739
+ *
2210
2740
  * NOTE: the stamp read is outside the transaction. A concurrent writer that removes
2211
2741
  * the row in between makes the signature bind a stamp that is no longer live; the
2212
2742
  * delete then matches nothing and the tombstone insert collides with the other
@@ -2221,7 +2751,8 @@ export class ControlDatabase {
2221
2751
  table: RemovableTable,
2222
2752
  keyValue: string,
2223
2753
  ownerKey: string,
2224
- signMessage: (message: Uint8Array) => string
2754
+ signMessage: (message: Uint8Array) => string,
2755
+ alongside?: (stampId: string) => Promise<void>
2225
2756
  ): Promise<boolean> {
2226
2757
  this.ensureInitialized();
2227
2758
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
@@ -2231,8 +2762,11 @@ export class ControlDatabase {
2231
2762
  return false;
2232
2763
  }
2233
2764
 
2234
- const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
2235
- await this.inTransaction(`delete ${table}`, () => this.execGuardedRemoval(removal, ownerKey));
2765
+ const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2766
+ await this.inTransaction(`delete ${table}`, async () => {
2767
+ await alongside?.(stampId);
2768
+ await this.execGuardedRemoval(removal, ownerKey);
2769
+ });
2236
2770
 
2237
2771
  log('%s deleted: %s (stamp retired)', table, keyValue);
2238
2772
  this.notifyGuardedDelete(removal.ref);
@@ -2242,7 +2776,7 @@ export class ControlDatabase {
2242
2776
  /**
2243
2777
  * Delete the row incarnation `removal` names and file its tombstone — the statement pair
2244
2778
  * every owner removal of a guarded row runs ({@link deleteGuardedRow},
2245
- * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
2779
+ * {@link deleteStrandAndPartyKey}, {@link rewritePendingJoin}). The caller supplies the
2246
2780
  * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
2247
2781
  * tombstone commits with it.
2248
2782
  *
@@ -2259,14 +2793,33 @@ export class ControlDatabase {
2259
2793
  with context OwnerKey = ?, Signature = ?
2260
2794
  where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
2261
2795
  `, [ownerKey, removal.signature, rowKey, stampId]);
2262
- // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
2263
- // than leaning on the column default — the seat-at-zero rule is load-bearing for
2264
- // reissueRevocations' monotonic bump, so state it at the write site.
2796
+ await this.execTombstone(removal.ref, removal.revocationSignature, ownerKey);
2797
+ }
2798
+
2799
+ /**
2800
+ * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2801
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
2802
+ * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
2803
+ * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2804
+ * lock and, where one is needed, the transaction.
2805
+ *
2806
+ * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
2807
+ * than leaning on the column default — the seat-at-zero rule is load-bearing for
2808
+ * {@link reissueRevocations}' monotonic bump, so state it at the write site.
2809
+ *
2810
+ * `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
2811
+ * equal), so a node that receives the row by replication can tell which owner filed it.
2812
+ */
2813
+ private async execTombstone(
2814
+ ref: RevokedRowRef | typeof REVOCATION_LEDGER_MARKER,
2815
+ revocationSignature: string,
2816
+ ownerKey: string
2817
+ ): Promise<void> {
2265
2818
  await this.db!.exec(`
2266
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
2819
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
2267
2820
  with context OwnerKey = ?, Signature = ?
2268
- values (?, ?, ?, 0)
2269
- `, [ownerKey, removal.revocationSignature, tableName, rowKey, stampId]);
2821
+ values (?, ?, ?, 0, ?, ?)
2822
+ `, [ownerKey, revocationSignature, ref.tableName, ref.rowKey, ref.stampId, ownerKey, revocationSignature]);
2270
2823
  }
2271
2824
 
2272
2825
  /**
@@ -2367,6 +2920,15 @@ export class ControlDatabase {
2367
2920
  // 0 on its first connected tick and holds there. If a party ever reaps many rows
2368
2921
  // in one pass, hoist the notify to one refresh after the sweep instead.
2369
2922
  await this.mutateCadrePeer('peer-reap', () => this.db!.exec(sql, [rowKey, stampId]));
2923
+ } else if (table === 'JoinRequest') {
2924
+ // The request's outcome row goes with it; the committed tombstone authorizes that delete
2925
+ // too. An outcome row held for a request this node never received (the request was
2926
+ // written alone and its outcome connected, before the growth-edge re-issue) is left: it
2927
+ // is unreadable, since every reader starts from the request.
2928
+ await this.lockedWithRetry(() => this.inTransaction('reap JoinRequest', async () => {
2929
+ await this.deleteJoinOutcomes(stampId);
2930
+ await this.db!.exec(sql, [rowKey, stampId]);
2931
+ }), {}, 'reap-JoinRequest');
2370
2932
  } else {
2371
2933
  await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
2372
2934
  }
@@ -2406,7 +2968,7 @@ export class ControlDatabase {
2406
2968
  * reads from this node (every other node already filters it by tombstone). A revoked
2407
2969
  * node therefore keeps its own copy of its own row; "a node that learns it has been
2408
2970
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
2409
- * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
2971
+ * `ValidationKey`, `JoinedStrand` and `JoinRequest` have no self notion and are not
2410
2972
  * special-cased.
2411
2973
  *
2412
2974
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
@@ -2502,7 +3064,7 @@ export class ControlDatabase {
2502
3064
  const signed = rows.map((row) => ({
2503
3065
  row,
2504
3066
  signature: signMessage(buildAuthorizationMessage(
2505
- 'CadreControl.Revocation', 'reissue',
3067
+ 'CadreControl.Revocation', 'reissue', this.config.partyId,
2506
3068
  [row.tableName, row.rowKey, row.stampId, String(reissuedAt)]
2507
3069
  )),
2508
3070
  }));
@@ -2574,21 +3136,14 @@ export class ControlDatabase {
2574
3136
  signMessage: (message: Uint8Array) => string
2575
3137
  ): Promise<RevocationLedgerOpenResult> {
2576
3138
  this.ensureInitialized();
2577
- const { tableName, rowKey, stampId } = REVOCATION_LEDGER_MARKER;
2578
- const signature = signMessage(buildAuthorizationMessage(
2579
- 'CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]
2580
- ));
3139
+ const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2581
3140
  try {
2582
3141
  return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
2583
3142
  if (await this.revocationLedgerFiled()) {
2584
3143
  return 'already-open';
2585
3144
  }
2586
- // Bare `exec`: already inside the write lock, which is NOT re-entrant.
2587
- await this.db!.exec(`
2588
- insert into CadreControl.Revocation (TableName, RowKey, StampId)
2589
- with context OwnerKey = ?, Signature = ?
2590
- values (?, ?, ?)
2591
- `, [ownerKey, signature, tableName, rowKey, stampId]);
3145
+ // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
3146
+ await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
2592
3147
  log('Revocation ledger marker filed');
2593
3148
  return 'opened';
2594
3149
  }, {}, 'revocation-ledger-open');
@@ -2693,15 +3248,18 @@ export class ControlDatabase {
2693
3248
  }
2694
3249
 
2695
3250
  /**
2696
- * Run a `CadrePeer` row mutation and notify the membership listener once it has
3251
+ * Run a membership-relevant mutation and notify the membership listener once it has
2697
3252
  * COMMITTED.
2698
3253
  *
2699
- * EVERY `CadrePeer` writer goes through here, with one documented exception
2700
- * ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
2701
- * the party-membership snapshot refresh automatic rather than a caller obligation. A
3254
+ * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
3255
+ * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
3256
+ * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
3257
+ * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
3258
+ * which seats both. That is what makes the party-membership snapshot refresh automatic
3259
+ * rather than a caller obligation. The name predates the `OwnerKey` writers. A
2702
3260
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
2703
- * event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
2704
- * `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
3261
+ * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
3262
+ * builds, and services constructed outside `CadreNode` entirely, never get
2705
3263
  * callbacks wired), so this is the one point on the write path that cannot be bypassed.
2706
3264
  *
2707
3265
  * `body` owns its own transaction if it needs one and must COMMIT before returning, so
@@ -2890,7 +3448,7 @@ export class ControlDatabase {
2890
3448
  }
2891
3449
  throw new Error(
2892
3450
  `mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
2893
- 'CadrePeer write must commit before the membership listener runs; move the ' +
3451
+ 'membership write must commit before the membership listener runs; move the ' +
2894
3452
  'mutateCadrePeer wrapper out to enclose the commit'
2895
3453
  );
2896
3454
  }
@@ -2980,7 +3538,7 @@ export class ControlDatabase {
2980
3538
 
2981
3539
  // Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
2982
3540
  // Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
2983
- const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
3541
+ const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
2984
3542
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
2985
3543
  ]);
2986
3544
  const signature = signMessage(message);
@@ -3417,6 +3975,446 @@ export class ControlDatabase {
3417
3975
  return false;
3418
3976
  }
3419
3977
 
3978
+ // ── Cadre invitations (CadreInvite / CadreInviteUsage) ─────────────────────────────────
3979
+
3980
+ /**
3981
+ * Mint and insert an owner-signed `CadreInvite`: an invitation to join THIS cadre that any
3982
+ * member machine can later redeem on the owner's behalf ({@link redeemCadreInvite}).
3983
+ *
3984
+ * The owner signs the whole row ({@link cadreInviteAddMessage}) — `ExpiresAt` through
3985
+ * {@link canonicalDatetime} so the signed segment byte-matches the stored `datetime`, as
3986
+ * {@link insertFormationInvite} does — and the stored `IssuerKey`/`IssuerSig` are pinned to
3987
+ * that signer by `CadreInvite.AuthorizedInsert`. The signature is part of the row because
3988
+ * the row travels: the returned {@link CadreInviteRow} is what the invitation bundle
3989
+ * carries to the holder, and {@link seatCadreInvite} re-inserts it verbatim on a member
3990
+ * that has not received it by replication.
3991
+ *
3992
+ * `key` is the invitation's ed25519 PUBLIC key; the caller minted the pair and hands the
3993
+ * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
3994
+ * same shape).
3995
+ *
3996
+ * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
3997
+ * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
3998
+ * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
3999
+ * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
4000
+ * @param invite.totalUses - how many redemptions it allows; omit for unlimited
4001
+ * @returns the row as stored, `expiresAt` in its canonical form
4002
+ */
4003
+ async insertCadreInvite(
4004
+ invite: {
4005
+ key: string;
4006
+ peerId?: string | null;
4007
+ grantsOwner: boolean;
4008
+ expiresAtMs?: number | null;
4009
+ totalUses?: number | null;
4010
+ },
4011
+ ownerKey: string,
4012
+ signMessage: (message: Uint8Array) => string
4013
+ ): Promise<CadreInviteRow> {
4014
+ this.ensureInitialized();
4015
+ const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
4016
+ const signed: CadreInviteSignedFields = {
4017
+ key,
4018
+ peerId: invite.peerId ?? null,
4019
+ grantsOwner: invite.grantsOwner,
4020
+ expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db!, invite.expiresAtMs),
4021
+ totalUses: invite.totalUses ?? null,
4022
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
4023
+ };
4024
+ const row: CadreInviteRow = {
4025
+ ...signed,
4026
+ issuerKey: ownerKey,
4027
+ issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
4028
+ };
4029
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
4030
+ log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
4031
+ return row;
4032
+ }
4033
+
4034
+ /**
4035
+ * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
4036
+ * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
4037
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
4038
+ *
4039
+ * @returns `true` when this call inserted the row, `false` when it was already present.
4040
+ * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
4041
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
4042
+ * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4043
+ */
4044
+ async seatCadreInvite(row: CadreInviteRow): Promise<boolean> {
4045
+ this.ensureInitialized();
4046
+ try {
4047
+ return await this.lockedWithRetry(async () => {
4048
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
4049
+ if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
4050
+ log('CadreInvite %s already seated; seat skipped', row.key);
4051
+ return false;
4052
+ }
4053
+ await this.execCadreInviteInsert(row);
4054
+ log('Cadre invitation seated from its bundle: %s', row.key);
4055
+ return true;
4056
+ }, {}, 'cadre-invite-seat');
4057
+ } catch (error) {
4058
+ if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
4059
+ throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
4060
+ }
4061
+ throw error;
4062
+ }
4063
+ }
4064
+
4065
+ /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
4066
+ private async execCadreInviteInsert(row: CadreInviteRow): Promise<void> {
4067
+ await this.db!.exec(`
4068
+ insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4069
+ with context OwnerKey = ?, Signature = ?
4070
+ values (?, ?, ?, ?, ?, ?, ?, ?)
4071
+ `, [
4072
+ row.issuerKey, row.issuerSig,
4073
+ row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
4074
+ row.issuerKey, row.issuerSig, row.stampId,
4075
+ ]);
4076
+ }
4077
+
4078
+ /**
4079
+ * Read one `CadreInvite` row by key, or null when absent. Withdrawn rows are returned too —
4080
+ * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
4081
+ * question, and `CadreInviteUsage.Authorized`'s at redemption.
4082
+ *
4083
+ * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
4084
+ * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4085
+ */
4086
+ async queryCadreInvite(key: string, retry = true): Promise<CadreInviteRow | null> {
4087
+ this.ensureInitialized();
4088
+ for (const row of await this.readRows(`${CADRE_INVITE_SELECT_SQL} where Key = ?`, [key], 'cadre-invite', retry)) {
4089
+ return cadreInviteRowOf(row);
4090
+ }
4091
+ return null;
4092
+ }
4093
+
4094
+ /**
4095
+ * Every `CadreInvite` row this node holds, withdrawn and expired ones included (see
4096
+ * {@link queryCadreInvite}). Read by the membership predicate when at least one `CadrePeer`
4097
+ * row is invitation-admitted; `retry: false` is forwarded from
4098
+ * {@link CadreNode.listAuthorizedMembers} on the membership-gate refresh path for
4099
+ * {@link queryCadrePeers}' reason.
4100
+ */
4101
+ async queryCadreInvites(retry = true): Promise<CadreInviteRow[]> {
4102
+ this.ensureInitialized();
4103
+ const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
4104
+ return rows.map(cadreInviteRowOf);
4105
+ }
4106
+
4107
+ /**
4108
+ * Every `CadreInviteUsage` row this node holds — the redemption records the membership
4109
+ * predicate verifies invitation-admitted rows against. Same `retry` contract as
4110
+ * {@link queryCadreInvites}.
4111
+ */
4112
+ async queryCadreInviteUsages(retry = true): Promise<CadreInviteUsageRow[]> {
4113
+ this.ensureInitialized();
4114
+ const rows = await this.readRows(
4115
+ 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
4116
+ undefined, 'cadre-invite-usages', retry
4117
+ );
4118
+ return rows.map(cadreInviteUsageRowOf);
4119
+ }
4120
+
4121
+ /**
4122
+ * How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
4123
+ * through the `CadreInviteUsageByInvite` index. As {@link countFormationUsage}: a permissive
4124
+ * PRE-check, not the cap — the cap is `CadreInviteUsage.Authorized`'s own committed count.
4125
+ *
4126
+ * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
4127
+ */
4128
+ async countCadreInviteUsage(key: string, retry = true): Promise<number> {
4129
+ this.ensureInitialized();
4130
+ const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
4131
+ for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
4132
+ return (row.Count as number) ?? 0;
4133
+ }
4134
+ return 0;
4135
+ }
4136
+
4137
+ /**
4138
+ * Is any cadre invitation still redeemable — not withdrawn, unexpired, with uses below its
4139
+ * `TotalUses`, and issued by a current owner? The conditions `CadreInviteUsage.Authorized`
4140
+ * applies, read ahead of time so the control-network connection gate can answer "does this
4141
+ * node expect a stranger?" with no key to ask about, exactly as
4142
+ * {@link hasOutstandingFormationInvite} answers it for strand formation. The expiry
4143
+ * comparison is `expiresAtMs <= now`, so an invitation the redemption would refuse never
4144
+ * holds the gate open.
4145
+ *
4146
+ * Withdrawal is read through {@link queryRevokedStamps}, the per-table retired-stamp seam
4147
+ * every other revocation-aware reader uses (and the one an isolated node can still answer).
4148
+ */
4149
+ async hasLiveCadreInvite(nowMs: number = Date.now()): Promise<boolean> {
4150
+ this.ensureInitialized();
4151
+ // NOTE: getOwnerKeys reads the retired OwnerKey stamps first, so this gate path scans
4152
+ // Revocation twice (once per table, the two in parallel). If the stranger path's decision
4153
+ // ever nears the admission gate's 2 s deadline, read the retired stamps once for both.
4154
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
4155
+ // NOTE: scans every CadreInvite row (expired and withdrawn ones included, since rows are
4156
+ // never deleted) on the stranger path of an inbound connection. Cadre-scale invitation
4157
+ // counts make that free today; if a long-lived cadre accumulates thousands and inbound
4158
+ // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
4159
+ // first needs every device they admitted re-vouched, so the chain no longer needs them).
4160
+ const metered: Array<{ key: string; totalUses: number }> = [];
4161
+ let unlimitedLive = false;
4162
+ for (const row of await this.readRows(
4163
+ 'select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite',
4164
+ undefined,
4165
+ 'live-cadre-invites'
4166
+ )) {
4167
+ const open = cadreInviteStillOpen(
4168
+ { stampId: row.StampId as string, issuerKey: row.IssuerKey as string, expiresAt: (row.ExpiresAt as string | null) ?? null },
4169
+ withdrawn, owners, nowMs
4170
+ );
4171
+ if (!open) {
4172
+ continue;
4173
+ }
4174
+ const totalUses = (row.TotalUses as number | null) ?? null;
4175
+ if (totalUses === null) {
4176
+ unlimitedLive = true;
4177
+ } else {
4178
+ metered.push({ key: row.Key as string, totalUses });
4179
+ }
4180
+ }
4181
+ if (unlimitedLive) {
4182
+ return true;
4183
+ }
4184
+ // NOTE: one retried read per metered invitation, each with its own read budget, on a path
4185
+ // the admission gate awaits under a 2 s fail-open deadline — the same shape and the same
4186
+ // revisit condition as hasOutstandingFormationInvite's loop.
4187
+ for (const invite of metered) {
4188
+ if (await this.countCadreInviteUsage(invite.key) < invite.totalUses) {
4189
+ return true;
4190
+ }
4191
+ }
4192
+ return false;
4193
+ }
4194
+
4195
+ /**
4196
+ * Is this one cadre invitation still redeemable here — the conditions
4197
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
4198
+ * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
4199
+ * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
4200
+ * judged by whichever owner it names as issuer. A permissive pre-check like
4201
+ * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
4202
+ * what enforce every condition.
4203
+ */
4204
+ async isCadreInviteLive(invite: CadreInviteRow, nowMs: number = Date.now()): Promise<boolean> {
4205
+ this.ensureInitialized();
4206
+ const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
4207
+ if (row === null) {
4208
+ return false;
4209
+ }
4210
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
4211
+ if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
4212
+ return false;
4213
+ }
4214
+ return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
4215
+ }
4216
+
4217
+ /**
4218
+ * Every `CadreInvite` row this node holds with its standing: how many redemptions are
4219
+ * recorded, whether it is withdrawn, and whether it is still redeemable — the same
4220
+ * conditions {@link hasLiveCadreInvite} applies, answered per row for an owner's listing
4221
+ * rather than short-circuited. One usage count per row, so this is the owner's
4222
+ * occasional read, not a gate's.
4223
+ */
4224
+ async listCadreInviteStatuses(nowMs: number = Date.now()): Promise<CadreInviteStatus[]> {
4225
+ this.ensureInitialized();
4226
+ const [invites, withdrawnStamps, owners] = await Promise.all([
4227
+ this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
4228
+ ]);
4229
+ const statuses: CadreInviteStatus[] = [];
4230
+ // NOTE: one retried count read per invitation, withdrawn and expired rows included, since
4231
+ // rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
4232
+ // usage counts in one statement or skip the count for rows that are no longer open.
4233
+ for (const invite of invites) {
4234
+ const usesRecorded = await this.countCadreInviteUsage(invite.key);
4235
+ const withdrawn = withdrawnStamps.has(invite.stampId);
4236
+ const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
4237
+ && (invite.totalUses === null || usesRecorded < invite.totalUses);
4238
+ statuses.push({ invite, live, withdrawn, usesRecorded });
4239
+ }
4240
+ return statuses;
4241
+ }
4242
+
4243
+ /**
4244
+ * Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
4245
+ * stamp, with the row kept. The row stays because every device the invitation admitted
4246
+ * proves its membership through it (`CadrePeer.AuthorizedInsert`'s consent branch,
4247
+ * `verifyInvitationAdmission`); `CadreInvite.NoDelete` refuses a delete, and
4248
+ * `Revocation.RowIsGone` admits a `'CadreInvite'` tombstone while the row lives. Afterwards
4249
+ * `CadreInviteUsage.Authorized` refuses every further redemption, and `CadreInvite.NotRevoked`
4250
+ * refuses re-seating the row from the holder's signed copy on any node holding the
4251
+ * tombstone.
4252
+ *
4253
+ * Idempotent: a no-op (no throw, nothing written) when the row is absent locally or already
4254
+ * withdrawn — `false` then, `true` when this call filed the tombstone. The owner must hold the
4255
+ * row to withdraw it, since the tombstone names the row's stamp.
4256
+ *
4257
+ * Fires the guarded-delete listener like a removal does, so a withdrawal committed while the
4258
+ * node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
4259
+ */
4260
+ async withdrawCadreInvite(
4261
+ key: string,
4262
+ ownerKey: string,
4263
+ signMessage: (message: Uint8Array) => string
4264
+ ): Promise<boolean> {
4265
+ this.ensureInitialized();
4266
+ const filed = await this.lockedWithRetry(async (): Promise<RevokedRowRef | null> => {
4267
+ // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
4268
+ const stampId = await this.queryStampId('CadreInvite', key, false);
4269
+ if (stampId === null) {
4270
+ log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
4271
+ return null;
4272
+ }
4273
+ if ((await this.queryRevokedStamps('CadreInvite', false)).has(stampId)) {
4274
+ log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
4275
+ return null;
4276
+ }
4277
+ const ref: RevokedRowRef = { tableName: 'CadreInvite', rowKey: key, stampId };
4278
+ await this.execTombstone(ref, signMessage(revocationTombstoneMessage(this.config.partyId, ref)), ownerKey);
4279
+ return ref;
4280
+ }, {}, 'cadre-invite-withdraw');
4281
+ if (filed === null) {
4282
+ return false;
4283
+ }
4284
+ log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
4285
+ this.notifyGuardedDelete(filed);
4286
+ return true;
4287
+ }
4288
+
4289
+ /**
4290
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
4291
+ * when the invitation grants ownership, as an owner — with NO owner signature. One
4292
+ * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
4293
+ * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
4294
+ * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
4295
+ * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
4296
+ * redemption and none can be held in reserve. The invitation must already be committed
4297
+ * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
4298
+ *
4299
+ * The two signatures are the holder's and the device's, minted on the device over
4300
+ * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
4301
+ * verifies them (by writing the row). `peerId` must be the identity multihash of `peerKey`:
4302
+ * the schema cannot derive it, so a writer asserts the pair and every reader re-derives it
4303
+ * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
4304
+ *
4305
+ * Idempotent for a retry after a dropped reply: when the device's row is already present
4306
+ * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
4307
+ * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
4308
+ * owner-granting invitation is completed: the usage row then names the existing peer stamp.
4309
+ * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
4310
+ * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
4311
+ *
4312
+ * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
4313
+ * every read passes `retry: false` and every stamp is minted per attempt, so a
4314
+ * {@link lockedWithRetry} re-run starts clean.
4315
+ *
4316
+ * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
4317
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
4318
+ */
4319
+ async redeemCadreInvite(params: {
4320
+ inviteKey: string;
4321
+ peerId: string;
4322
+ /** The device's ed25519 public key (base64url); `peerId` is its identity multihash. */
4323
+ peerKey: string;
4324
+ /** The device's dialable addresses, if known; stored comma-joined as `authorizePeer` does. */
4325
+ multiaddrs?: string[];
4326
+ /** Single-use nonce minted by the device; both signatures cover it. */
4327
+ usageStampId: string;
4328
+ /** The holder's signature with the invitation private key over the `'redeem'` digest. */
4329
+ inviteSig: string;
4330
+ /** The device's signature with its own key over the `'consent'` digest. */
4331
+ peerSig: string;
4332
+ nowMs?: number;
4333
+ }): Promise<CadreInviteRedemptionResult> {
4334
+ this.ensureInitialized();
4335
+ const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
4336
+ const nowMs = params.nowMs ?? Date.now();
4337
+ const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
4338
+ log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
4339
+
4340
+ return this.mutateCadrePeer('cadre-invite-redeem', async () => {
4341
+ const invite = await this.queryCadreInvite(inviteKey, false);
4342
+ if (invite === null) {
4343
+ throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
4344
+ }
4345
+ const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
4346
+ if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
4347
+ log('redeemCadreInvite: %s already admitted; nothing written', peerId);
4348
+ return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
4349
+ }
4350
+ await this.assertCadreInviteSeatRemains(invite);
4351
+
4352
+ // Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
4353
+ // the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
4354
+ // attempt rather than re-presenting a stamp the usage row may already name.
4355
+ const peerStampId = seated.peerStampId ?? generateStampId(peerId);
4356
+ const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
4357
+ const nowCanonical = await canonicalDatetime(this.db!, nowMs);
4358
+ await this.inTransaction('cadre-invite-redeem', async () => {
4359
+ if (seated.peerStampId === null) {
4360
+ await this.db!.exec(`
4361
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
4362
+ with context OwnerKey = null, Signature = null
4363
+ values (?, ?, ?, ?, null, ?, ?, null, ?)
4364
+ `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
4365
+ }
4366
+ if (ownerStampId !== null && seated.ownerStampId === null) {
4367
+ await this.db!.exec(`
4368
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
4369
+ with context OwnerKey = null, Signature = null
4370
+ values (?, ?, ?, null, ?)
4371
+ `, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
4372
+ }
4373
+ await this.db!.exec(`
4374
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
4375
+ with context Now = ?
4376
+ values (?, ?, ?, ?, ?, ?, ?, ?)
4377
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
4378
+ });
4379
+ log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
4380
+ return { alreadyMember: false, peerStampId, ownerStampId };
4381
+ });
4382
+ }
4383
+
4384
+ /**
4385
+ * The rows a redemption would seat that are already present: the device's `CadrePeer`
4386
+ * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
4387
+ * reads ({@link queryStampId}), so a physically present row counts; inside the locked
4388
+ * body, so never retried.
4389
+ */
4390
+ private async readSeatedAdmission(
4391
+ peerId: string,
4392
+ peerKey: string,
4393
+ grantsOwner: boolean
4394
+ ): Promise<{ peerStampId: string | null; ownerStampId: string | null }> {
4395
+ return {
4396
+ peerStampId: await this.queryStampId('CadrePeer', peerId, false),
4397
+ ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
4398
+ };
4399
+ }
4400
+
4401
+ /**
4402
+ * Refuse a redemption that would consume a seat the cadre invitation does not have, by
4403
+ * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
4404
+ * {@link assertSeatRemains}, for the same reason: the schema's cap clause refuses as a
4405
+ * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
4406
+ * committed count inside the locked body, so on a same-node race the loser is refused here.
4407
+ */
4408
+ private async assertCadreInviteSeatRemains(invite: CadreInviteRow): Promise<void> {
4409
+ if (invite.totalUses === null) {
4410
+ return;
4411
+ }
4412
+ const used = await this.countCadreInviteUsage(invite.key, false);
4413
+ if (used >= invite.totalUses) {
4414
+ throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
4415
+ }
4416
+ }
4417
+
3420
4418
  /**
3421
4419
  * Close the database and cleanup resources.
3422
4420
  *