@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,13 +1,14 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { Database, registerPlugin } from '@quereus/quereus';
3
+ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlugin } from '@quereus/quereus';
4
4
  import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
5
5
  import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
6
6
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
7
7
  import { CONTROL_SCHEMA } from './control-schema.js';
8
8
  import { canonicalDatetime } from './canonical-datetime.js';
9
- import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
10
- import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
9
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
10
+ import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
11
+ import { verifyCadreInviteRow } from './peer-authorization.js';
11
12
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
12
13
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
13
14
  import { chainMessages } from './control-retry.js';
@@ -75,13 +76,15 @@ export class FormationAbortedError extends Error {
75
76
  }
76
77
  }
77
78
  /**
78
- * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
79
- * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
80
- * seat.
79
+ * The invitation's seat budget is already spent: the count of recorded usage rows for the
80
+ * invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
81
+ * is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
82
+ * redeemed against.
81
83
  *
82
84
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
83
85
  * the write, inside {@link ControlDatabase.redeemInvitation} /
84
- * {@link ControlDatabase.recordFormationUsage}, and off the unlocked pre-check
86
+ * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
87
+ * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
85
88
  * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
86
89
  * pass — and off nothing else. It exists because
87
90
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
@@ -101,6 +104,23 @@ export class InvitationExhaustedError extends Error {
101
104
  this.name = 'InvitationExhaustedError';
102
105
  }
103
106
  }
107
+ /**
108
+ * {@link ControlDatabase.seatCadreInvite} was refused by `CadreInvite.AuthorizedInsert`: the
109
+ * row's `IssuerKey` is not an owner on THIS node, or its `IssuerSig` does not verify. The
110
+ * common cause is the first — the issuing owner's `OwnerKey` row has not replicated here yet,
111
+ * or that owner has since been removed — which is why the redemption protocol maps this to a
112
+ * retryable code; a tampered row fails the same way and simply never succeeds.
113
+ */
114
+ export class CadreInviteIssuerUnknownError extends Error {
115
+ constructor(inviteKey, issuerKey, options) {
116
+ super(`Cadre invitation ${inviteKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
117
+ this.inviteKey = inviteKey;
118
+ this.issuerKey = issuerKey;
119
+ this.name = 'CadreInviteIssuerUnknownError';
120
+ }
121
+ }
122
+ /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
123
+ const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
104
124
  /**
105
125
  * Parse a stored Quereus `datetime` value into epoch milliseconds.
106
126
  *
@@ -132,14 +152,14 @@ function parseNullableStoredDatetimeMs(value) {
132
152
  * The message is a SINGLE framed SHA-256 digest over the ordered field vector from
133
153
  * {@link controlAuthorizationFields} (the crypto plugin's injective multi-field
134
154
  * encoding): two fixed literals — the domain tag naming the table rule and the action
135
- * tag — followed by the row fields in the schema's fixed order, with the single-use
136
- * StampId as the final field where the table has one:
155
+ * tag — then the party id, then the row fields in the schema's fixed order, with the
156
+ * single-use StampId as the final field where the table has one:
137
157
  *
138
- * message = sha256(encodeFields([domain, action, field_1, ..., StampId])) // raw digest bytes
158
+ * message = sha256(encodeFields([domain, action, partyId, field_1, ..., StampId])) // raw digest bytes
139
159
  *
140
160
  * ed25519 signs these raw digest bytes DIRECTLY (no second hash). The SQL constraints
141
161
  * verify the identical bytes with one variadic call
142
- * (`verify(digest('CadreControl.X', 'add', field_1, ...), context.Signature, A.Key, 'ed25519')`):
162
+ * (`verify(digest('CadreControl.X', 'add', party_id(), field_1, ...), context.Signature, A.Key, 'ed25519')`):
143
163
  * SQL `digest(...)` returns the base64url string of the same digest, which `verify`'s default
144
164
  * base64url input encoding decodes back to those raw bytes — so signer and verifier
145
165
  * operate on the same bytes. Every field is TEXT on both sides (the SQL columns are
@@ -147,12 +167,43 @@ function parseNullableStoredDatetimeMs(value) {
147
167
  * type tags agree. Binding the row contents closes captured-stamp replay; the leading
148
168
  * domain/action tags scope the signature to ONE table rule, so an approval minted for
149
169
  * one constraint can never satisfy another (e.g. a ValidationKey enrollment can no
150
- * longer double as an OwnerKey enrollment). Single source of truth: every signed writer
151
- * (and every test/harness signer) MUST build the message through this function with the
152
- * schema's tags and field order, or `verify` will reject the row.
170
+ * longer double as an OwnerKey enrollment); the party id scopes it to ONE party, so two
171
+ * parties sharing an owner key never accept each other's approvals. `partyId` is the
172
+ * party the signer is configured for, and the SQL side reads its own through the
173
+ * node-local `party_id()` ({@link registerPartyIdFunction}). Single source of truth:
174
+ * every signed writer (and every test/harness signer) MUST build the message through
175
+ * this function with the schema's tags and field order, or `verify` will reject the row.
176
+ */
177
+ export function buildAuthorizationMessage(domain, action, partyId, rowFields) {
178
+ return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'bytes');
179
+ }
180
+ /**
181
+ * {@link buildAuthorizationMessage} for the two party-free digests (`UnboundControlDomain`):
182
+ * the same framed digest with no party id after the tags.
183
+ */
184
+ function buildUnboundAuthorizationMessage(domain, action, rowFields) {
185
+ return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes');
186
+ }
187
+ /**
188
+ * Register the zero-argument scalar `party_id()` on `db`: the party id this machine is
189
+ * configured for, which every party-bound `digest(...)` in the control schema puts third.
190
+ * Node-local by design — a `context` value would be the writer's choice (a cohort member
191
+ * that re-ran the statement would replay it as a literal), and a stored `Party` table would
192
+ * be replicated data the schema already treats as untrusted. A cohort member that re-runs a
193
+ * statement therefore evaluates its OWN party id, and the schema text stays identical for
194
+ * every party. Each `Database` carries its own registration, so a process serving two
195
+ * parties holds two.
196
+ *
197
+ * NOTE: flagged deterministic, so the planner may fold the call into a plan; plans are
198
+ * per `Database` today. If Quereus ever shares plans across `Database` instances, drop
199
+ * the DETERMINISTIC flag or one party's id would be folded into another's checks.
200
+ *
201
+ * `ControlDatabase.initialize` calls this before hydrating persisted schemas, because a
202
+ * warm restart parses the persisted CHECK text, which calls it. Exported for specs that
203
+ * build their own `Database` around a copy of a bound constraint.
153
204
  */
154
- export function buildAuthorizationMessage(domain, action, rowFields) {
155
- return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'bytes');
205
+ export function registerPartyIdFunction(db, partyId) {
206
+ db.registerFunction(createScalarFunction({ name: 'party_id', numArgs: 0, flags: FunctionFlags.UTF8 | FunctionFlags.DETERMINISTIC, returnType: TEXT_RETURN }, () => partyId));
156
207
  }
157
208
  /**
158
209
  * The exact bytes an outside approver signs to authorize ONE redemption of a
@@ -169,9 +220,13 @@ export function buildAuthorizationMessage(domain, action, rowFields) {
169
220
  * that same nonce, then pass BOTH the nonce and the signature to
170
221
  * {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}:
171
222
  * signing one nonce and inserting another fails the CHECK.
223
+ *
224
+ * No party id, unlike every owner digest: the approver is sent only these five fields
225
+ * (`formation-approval.ts`), and the `token` it binds is a random secret seated in one
226
+ * party's `FormationInvite` table (the schema's `FormationUsage.Authorized` comment).
172
227
  */
173
228
  export function formationVouchMessage(fields) {
174
- return buildAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
229
+ return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'vouch', [
175
230
  fields.token, fields.usageStampId, fields.strandId, fields.peerKey, fields.disclosure,
176
231
  ]);
177
232
  }
@@ -191,13 +246,48 @@ export function formationVouchMessage(fields) {
191
246
  * the strand when it signs (a bound invite's host strand arrives only in the result
192
247
  * frame; an unbound strand is minted by the responder). The responder cannot
193
248
  * substitute one anyway — a bound invite is pinned to its own strand by `Authorized`,
194
- * and an unbound redemption mints a fresh strand, so there is no victim to name.
249
+ * and an unbound redemption mints a fresh strand, so there is no victim to name. The
250
+ * party id is absent for the same reason (it too arrives only in the result frame) and
251
+ * the `token` scopes the digest to one party instead, as for {@link formationVouchMessage}.
195
252
  */
196
253
  export function formationConsentMessage(fields) {
197
- return buildAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
254
+ return buildUnboundAuthorizationMessage('CadreControl.FormationUsage', 'consent', [
198
255
  fields.token, fields.usageStampId, fields.peerKey, fields.disclosure,
199
256
  ]);
200
257
  }
258
+ /**
259
+ * The exact bytes an owner signs to seat a `CadreInvite` row — the TS mirror of the `'add'`
260
+ * digest in `CadreInvite.AuthorizedInsert`, over the whole row in the schema's order with
261
+ * nullable columns as `''` (`cadreInviteRowFields`, shared with the base64url verifier
262
+ * `peer-authorization.ts`'s `cadreInviteAddDigest`). `expiresAt` must already be the stored
263
+ * canonical `datetime` string ({@link canonicalDatetime}), as {@link ControlDatabase.insertCadreInvite}
264
+ * produces it, or the signed segment will not match what the CHECK sees after coercion.
265
+ */
266
+ export function cadreInviteAddMessage(partyId, row) {
267
+ return buildAuthorizationMessage('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
268
+ }
269
+ /**
270
+ * The exact bytes the HOLDER of a cadre invitation signs, with the invitation's private key,
271
+ * to redeem it once — the TS mirror of the `'redeem'` digest `CadreInviteUsage.InvitePossessed`
272
+ * verifies against the invitation's public key. Binding the device and the nonce means a
273
+ * captured redemption can admit no other device and spend no other use.
274
+ */
275
+ export function cadreInviteRedeemMessage(partyId, fields) {
276
+ return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'redeem', partyId, [
277
+ fields.inviteKey, fields.usageStampId, fields.peerKey,
278
+ ]);
279
+ }
280
+ /**
281
+ * The exact bytes the DEVICE signs, with its own key, to consent to being admitted by one
282
+ * redemption — the TS mirror of the `'consent'` digest `CadreInviteUsage.PeerConsented`
283
+ * verifies against `PeerKey`. Same fields as {@link cadreInviteRedeemMessage} under a
284
+ * distinct action tag, so neither signature can stand in for the other.
285
+ */
286
+ export function cadreInviteConsentMessage(partyId, fields) {
287
+ return buildAuthorizationMessage('CadreControl.CadreInviteUsage', 'consent', partyId, [
288
+ fields.inviteKey, fields.usageStampId, fields.peerKey,
289
+ ]);
290
+ }
201
291
  /** Runtime guard for the dynamic-`from` count, over the one table list. */
202
292
  const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
203
293
  /**
@@ -213,14 +303,33 @@ const GUARDED_KEY_COLUMN = {
213
303
  Strand: 'Id',
214
304
  StrandPartyKey: 'Id',
215
305
  JoinedStrand: 'Id',
216
- PendingJoin: 'Id',
306
+ JoinRequest: 'Id',
217
307
  DeviceToken: 'PeerId',
308
+ CadreInvite: 'Key',
218
309
  };
219
- function signGuardedRemoval(tableName, rowKey, stampId, signMessage) {
310
+ /**
311
+ * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
312
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
313
+ * tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
314
+ * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
315
+ * `revocationDigest`.
316
+ */
317
+ function revocationTombstoneMessage(partyId, ref) {
318
+ return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
319
+ }
320
+ /**
321
+ * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
322
+ * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
323
+ */
324
+ function ownerKeyAddMessage(partyId, key, stampId) {
325
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
326
+ }
327
+ function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
328
+ const ref = { tableName, rowKey, stampId };
220
329
  return {
221
- ref: { tableName, rowKey, stampId },
222
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', [rowKey, stampId])),
223
- revocationSignature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId])),
330
+ ref,
331
+ signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
332
+ revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
224
333
  };
225
334
  }
226
335
  /**
@@ -254,8 +363,8 @@ export function isStrandIdConflict(error, table) {
254
363
  return errorChainMatches(error, idConflictPattern(table));
255
364
  }
256
365
  /**
257
- * Did {@link ControlDatabase.insertPendingJoin} fail because the party already holds a
258
- * `PendingJoin` row for this invitation? The caller adopts the existing row instead.
366
+ * Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
367
+ * `JoinRequest` row for this invitation? The caller adopts the existing request instead.
259
368
  * Text-matched, failing closed, like {@link isStrandIdConflict}.
260
369
  *
261
370
  * NOTE: the existing row may be one {@link ControlDatabase.queryPendingJoin} hides (its stamp
@@ -263,10 +372,10 @@ export function isStrandIdConflict(error, table) {
263
372
  * until the next reap pass.
264
373
  */
265
374
  export function isPendingJoinConflict(error) {
266
- return errorChainMatches(error, idConflictPattern('PendingJoin'));
375
+ return errorChainMatches(error, idConflictPattern('JoinRequest'));
267
376
  }
268
377
  /**
269
- * The `PendingJoin.Id` of an invitation: the base64url sha256 `digest` of its token. One row
378
+ * The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
270
379
  * per invitation, and the `Revocation` tombstone that names a removed row carries this, never
271
380
  * the bearer token itself.
272
381
  */
@@ -274,17 +383,15 @@ export function pendingJoinId(token) {
274
383
  return digest([token], 'sha256', 'base64url');
275
384
  }
276
385
  /**
277
- * {@link ControlDatabase.replacePendingJoin} found that the live `PendingJoin` row is not the
278
- * incarnation the caller read: another write replaced or removed it first (a sibling machine
279
- * recorded an outcome, or the user dismissed the join). Nothing was written; the caller
280
- * re-reads and decides again. `liveStampId` is null when the row is gone.
386
+ * A pending-join write found that the live join is not the one the caller read: another write
387
+ * recorded an outcome, asked again or dismissed it first. Nothing was written; the caller
388
+ * decides again from `live`, which is null when the request is gone.
281
389
  */
282
390
  export class PendingJoinChangedError extends Error {
283
- constructor(id, expectedStampId, liveStampId) {
284
- super(`PendingJoin ${id} changed: expected stamp ${expectedStampId}, found ${liveStampId ?? 'no row'}`);
391
+ constructor(id, live) {
392
+ super(`Pending join ${id} changed: ${live === null ? 'no request' : `request ${live.StampId}, ${live.outcome?.kind ?? 'pending'}`}`);
285
393
  this.id = id;
286
- this.expectedStampId = expectedStampId;
287
- this.liveStampId = liveStampId;
394
+ this.live = live;
288
395
  this.name = 'PendingJoinChangedError';
289
396
  }
290
397
  }
@@ -315,6 +422,49 @@ function isRevocationLedgerConflict(error) {
315
422
  function errorChainMatches(error, pattern) {
316
423
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
317
424
  }
425
+ /**
426
+ * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
427
+ * redemption reply, carries.
428
+ *
429
+ * Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
430
+ * Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
431
+ * key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
432
+ * `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
433
+ * or unparsable id yields a non-owner rather than failing the whole projection.
434
+ *
435
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
436
+ * the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
437
+ * peers first — not a trust decision, and the receiver re-derives real trust from its own
438
+ * anchor. So a polluted table costs at most a wasted dial, while anchoring here would
439
+ * silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
440
+ * anything the receiver TRUSTS, move it to the anchor.
441
+ */
442
+ export function projectSeedPeers(rows, ownerKeys) {
443
+ return rows.map(({ peerId, multiaddr }) => {
444
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
445
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
446
+ return {
447
+ peerId,
448
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
449
+ isOwner,
450
+ ...(isOwner ? { publicKey: pubKeyB64 } : {}),
451
+ };
452
+ });
453
+ }
454
+ /**
455
+ * The conditions a cadre invitation must still meet to be redeemed, short of its use count:
456
+ * not withdrawn, unexpired (`expiresAt <= now` is expired, as the redemption refuses it),
457
+ * and issued by a current owner. Shared by {@link ControlDatabase.hasLiveCadreInvite},
458
+ * {@link ControlDatabase.isCadreInviteLive} and {@link ControlDatabase.listCadreInviteStatuses}
459
+ * so they cannot drift from each other, or from `CadreInviteUsage.Authorized`.
460
+ */
461
+ function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
462
+ if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
463
+ return false;
464
+ }
465
+ const expiresAtMs = parseNullableStoredDatetimeMs(row.expiresAt);
466
+ return expiresAtMs === null || expiresAtMs > nowMs;
467
+ }
318
468
  /**
319
469
  * Guarded tables a node may reap locally once their tombstone has committed — the
320
470
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
@@ -323,21 +473,54 @@ function errorChainMatches(error, pattern) {
323
473
  * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
324
474
  * future change). `JoinedStrand` also carries a `MemberPrivateKey` but IS reapable: that
325
475
  * secret is the joined strand's shared read key, which its founding party and every member
326
- * hold, so a reaped row is recoverable by re-forming. `PendingJoin` is reapable for the same
476
+ * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
327
477
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
328
478
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
329
- * party-bricking hazard.
479
+ * party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
480
+ * withdrawal over a row that stays), so there is nothing to reap.
330
481
  */
331
- export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'PendingJoin'];
482
+ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
332
483
  /**
333
484
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
334
485
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
335
- * naming an excluded table (`Strand`, `OwnerKey`) is skipped silently — `Strand`
336
- * tombstones are the common case in a party that has unpublished a strand, so a log line
337
- * per skip would be per-pass noise.
486
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
487
+ * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
488
+ * log line per skip would be per-pass noise.
338
489
  */
339
490
  const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
340
491
  const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
492
+ /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
493
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
494
+ /**
495
+ * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
496
+ * engine-canonical string it reads back as — the form the `'add'` signature covers — never
497
+ * parsed to ms here; `GrantsOwner` is the schema's 0/1 integer.
498
+ */
499
+ function cadreInviteRowOf(row) {
500
+ return {
501
+ key: row.Key,
502
+ peerId: row.PeerId ?? null,
503
+ grantsOwner: Number(row.GrantsOwner) === 1,
504
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
505
+ totalUses: row.TotalUses ?? null,
506
+ issuerKey: row.IssuerKey,
507
+ issuerSig: row.IssuerSig,
508
+ stampId: row.StampId,
509
+ };
510
+ }
511
+ /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
512
+ function cadreInviteUsageRowOf(row) {
513
+ return {
514
+ usageStampId: row.UsageStampId,
515
+ inviteKey: row.InviteKey,
516
+ peerId: row.PeerId,
517
+ peerKey: row.PeerKey,
518
+ peerStampId: row.PeerStampId,
519
+ ownerStampId: row.OwnerStampId ?? null,
520
+ inviteSig: row.InviteSig,
521
+ peerSig: row.PeerSig,
522
+ };
523
+ }
341
524
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
342
525
  function joinedStrandRow(row) {
343
526
  return {
@@ -347,47 +530,79 @@ function joinedStrandRow(row) {
347
530
  FounderOwnerKey: null,
348
531
  };
349
532
  }
350
- /** Every `PendingJoin` column, in the schema's order. */
351
- const PENDING_JOIN_COLUMNS = 'Id, Invitation, Disclosure, RequestedAt, ExpiresAt, Outcome, OutcomeAt, StrandId, MembershipInvite, FailureCode, FailureReason, StampId';
352
- const PENDING_JOIN_INSERT_SQL = `
353
- insert into CadreControl.PendingJoin (${PENDING_JOIN_COLUMNS})
533
+ const JOIN_REQUEST_INSERT_SQL = `
534
+ insert into CadreControl.JoinRequest (Id, Invitation, Disclosure, RequestedAt, ExpiresAt, StampId)
535
+ with context OwnerKey = ?, Signature = ?
536
+ values (?, ?, ?, ?, ?, ?)
537
+ `;
538
+ const JOIN_SUCCESS_INSERT_SQL = `
539
+ insert into CadreControl.JoinSuccess (RequestStampId, RecordedAt, StrandId, MembershipInvite)
540
+ with context OwnerKey = ?, Signature = ?
541
+ values (?, ?, ?, ?)
542
+ `;
543
+ const JOIN_FAILURE_INSERT_SQL = `
544
+ insert into CadreControl.JoinFailure (RequestStampId, RecordedAt, Code, Reason)
354
545
  with context OwnerKey = ?, Signature = ?
355
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
546
+ values (?, ?, ?, ?)
547
+ `;
548
+ /**
549
+ * A request with its outcome in one statement: the outcome tables are keyed by the request's
550
+ * stamp, so the join finds exactly this incarnation's outcome.
551
+ */
552
+ const PENDING_JOIN_SELECT_SQL = `
553
+ select R.Id, R.Invitation, R.Disclosure, R.RequestedAt, R.ExpiresAt, R.StampId,
554
+ S.RecordedAt as JoinedAt, S.StrandId, S.MembershipInvite,
555
+ F.RecordedAt as FailedAt, F.Code, F.Reason
556
+ from CadreControl.JoinRequest R
557
+ left join CadreControl.JoinSuccess S on S.RequestStampId = R.StampId
558
+ left join CadreControl.JoinFailure F on F.RequestStampId = R.StampId
356
559
  `;
357
- /** A `PendingJoin` row as read; the schema's `KnownOutcome` check backs the `Outcome` cast. */
358
- function pendingJoinRow(row) {
560
+ /** A {@link PENDING_JOIN_SELECT_SQL} row as read. */
561
+ function pendingJoinOf(row) {
359
562
  return {
360
563
  Id: row.Id,
361
564
  Invitation: row.Invitation,
362
565
  Disclosure: row.Disclosure,
363
566
  RequestedAt: row.RequestedAt,
364
567
  ExpiresAt: row.ExpiresAt,
365
- Outcome: row.Outcome,
366
- OutcomeAt: row.OutcomeAt,
367
- StrandId: row.StrandId,
368
- MembershipInvite: row.MembershipInvite,
369
- FailureCode: row.FailureCode,
370
- FailureReason: row.FailureReason,
371
568
  StampId: row.StampId,
569
+ outcome: joinOutcomeOf(row),
372
570
  };
373
571
  }
572
+ /** The outcome half of a {@link PENDING_JOIN_SELECT_SQL} row; a request never has both (`JoinSuccess.NoFailure`, `JoinFailure.NotJoined`). */
573
+ function joinOutcomeOf(row) {
574
+ if (typeof row.JoinedAt === 'number') {
575
+ return { kind: 'joined', RecordedAt: row.JoinedAt, StrandId: row.StrandId, MembershipInvite: row.MembershipInvite };
576
+ }
577
+ if (typeof row.FailedAt === 'number') {
578
+ return { kind: 'failed', RecordedAt: row.FailedAt, Code: row.Code, Reason: row.Reason };
579
+ }
580
+ return null;
581
+ }
582
+ /** The fields `JoinRequest.AuthorizedInsert` signs, in the schema's order; integers sign as their text. */
583
+ function joinRequestAddFields(row) {
584
+ return [row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt), row.StampId];
585
+ }
374
586
  /**
375
- * The row fields `PendingJoin.AuthorizedInsert` signs, in the schema's order: a null column
376
- * signs as `''` and an integer as its text (`cast(... as text)` on the SQL side).
587
+ * The owner-signed insert of `outcome` for the request incarnation `requestStampId`: the
588
+ * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
589
+ * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
377
590
  */
378
- function pendingJoinAddFields(row) {
379
- return [
380
- row.Id, row.Invitation, row.Disclosure, String(row.RequestedAt), String(row.ExpiresAt),
381
- row.Outcome ?? '', row.OutcomeAt === null ? '' : String(row.OutcomeAt), row.StrandId ?? '',
382
- row.MembershipInvite ?? '', row.FailureCode ?? '', row.FailureReason ?? '', row.StampId,
383
- ];
384
- }
385
- /** The values of {@link PENDING_JOIN_INSERT_SQL}'s columns, in {@link PENDING_JOIN_COLUMNS} order. */
386
- function pendingJoinValues(row) {
387
- return [
388
- row.Id, row.Invitation, row.Disclosure, row.RequestedAt, row.ExpiresAt, row.Outcome, row.OutcomeAt,
389
- row.StrandId, row.MembershipInvite, row.FailureCode, row.FailureReason, row.StampId,
390
- ];
591
+ function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
592
+ const at = String(outcome.RecordedAt);
593
+ const { table, sql, fields, values } = outcome.kind === 'joined'
594
+ ? {
595
+ table: 'JoinSuccess', sql: JOIN_SUCCESS_INSERT_SQL,
596
+ fields: [requestStampId, at, outcome.StrandId, outcome.MembershipInvite ?? ''],
597
+ values: [requestStampId, outcome.RecordedAt, outcome.StrandId, outcome.MembershipInvite],
598
+ }
599
+ : {
600
+ table: 'JoinFailure', sql: JOIN_FAILURE_INSERT_SQL,
601
+ fields: [requestStampId, at, outcome.Code, outcome.Reason],
602
+ values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
603
+ };
604
+ const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
605
+ return { sql, params: [ownerKey, signature, ...values] };
391
606
  }
392
607
  /**
393
608
  * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
@@ -424,6 +639,9 @@ export class ControlDatabase {
424
639
  * backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
425
640
  */
426
641
  this.controlReadRetryPacing = {};
642
+ if (typeof config.partyId !== 'string' || config.partyId.length === 0) {
643
+ throw new Error('ControlDatabase requires a non-empty partyId: every control approval is bound to it');
644
+ }
427
645
  this.config = config;
428
646
  }
429
647
  /**
@@ -459,6 +677,8 @@ export class ControlDatabase {
459
677
  this.db.registerFunction(func.schema);
460
678
  }
461
679
  timing('[controlDb] optimysticPlugin: %dms', Math.round(performance.now() - t0));
680
+ // Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
681
+ registerPartyIdFunction(this.db, this.config.partyId);
462
682
  this.collectionFactory = pluginResult.collectionFactory;
463
683
  // Inject the libp2p node into the collection factory
464
684
  t0 = performance.now();
@@ -810,27 +1030,31 @@ export class ControlDatabase {
810
1030
  return rows.length === 0 ? null : joinedStrandRow(rows[0]);
811
1031
  }
812
1032
  /**
813
- * Every join this party asked for and has not dismissed (`CadreControl.PendingJoin`),
814
- * pending or finished.
1033
+ * Every join this party asked for and has not dismissed, pending or finished: each
1034
+ * `CadreControl.JoinRequest` row with its `JoinSuccess` / `JoinFailure` row.
815
1035
  *
816
- * Rows whose `StampId` is retired in `Revocation` are dropped, as {@link queryCadrePeers}
817
- * drops them: such a row is either a replaced or dismissed incarnation this node still
818
- * holds until the reap sweep removes it, or one re-seated by a replayed approval on a node
819
- * that had not yet seen the tombstone. Either way it must not bring a dismissed join back.
1036
+ * A request whose `StampId` is retired in `Revocation` is dropped, as {@link queryCadrePeers}
1037
+ * drops such rows: it is a re-asked or dismissed incarnation this node still holds until the
1038
+ * reap sweep removes it, or one re-seated by a replayed approval on a node that had not yet
1039
+ * seen the tombstone. Either way it must not bring a dismissed join back.
820
1040
  */
821
1041
  async queryPendingJoins() {
822
1042
  this.ensureInitialized();
823
- const revoked = await this.queryRevokedStamps('PendingJoin');
824
- const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin`, undefined, 'pending-joins');
825
- return rows.map(pendingJoinRow).filter(row => !revoked.has(row.StampId));
1043
+ const revoked = await this.queryRevokedStamps('JoinRequest');
1044
+ const rows = await this.readRows(PENDING_JOIN_SELECT_SQL, undefined, 'pending-joins');
1045
+ return rows.map(pendingJoinOf).filter(join => !revoked.has(join.StampId));
826
1046
  }
827
- /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the row with this id, or null. */
1047
+ /** Single-row sibling of {@link queryPendingJoins}, with the same retired-stamp filter: the join with this id, or null. */
828
1048
  async queryPendingJoin(id) {
829
1049
  this.ensureInitialized();
830
- const revoked = await this.queryRevokedStamps('PendingJoin');
831
- const rows = await this.readRows(`select ${PENDING_JOIN_COLUMNS} from CadreControl.PendingJoin where Id = ?`, [id], 'pending-join');
832
- const row = rows.length === 0 ? null : pendingJoinRow(rows[0]);
833
- return row === null || revoked.has(row.StampId) ? null : row;
1050
+ const revoked = await this.queryRevokedStamps('JoinRequest');
1051
+ const join = await this.readPendingJoin(id, true);
1052
+ return join === null || revoked.has(join.StampId) ? null : join;
1053
+ }
1054
+ /** The raw read behind {@link queryPendingJoin}, with no retired-stamp filter. `retry: false` inside a locked write body. */
1055
+ async readPendingJoin(id, retry) {
1056
+ const rows = await this.readRows(`${PENDING_JOIN_SELECT_SQL} where R.Id = ?`, [id], 'pending-join', retry);
1057
+ return rows.length === 0 ? null : pendingJoinOf(rows[0]);
834
1058
  }
835
1059
  /**
836
1060
  * Count rows in a CadreControl table as seen by THIS database instance.
@@ -863,6 +1087,9 @@ export class ControlDatabase {
863
1087
  /**
864
1088
  * Check whether any owner key exists in the control database.
865
1089
  * Used to decide whether a fresh-party genesis insert is required.
1090
+ *
1091
+ * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1092
+ * insert is refused by the schema whenever any physical row exists, so this must see one.
866
1093
  */
867
1094
  async hasOwnerKey() {
868
1095
  this.ensureInitialized();
@@ -893,14 +1120,65 @@ export class ControlDatabase {
893
1120
  * trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
894
1121
  * also the owner-identity source for `queryPeers`, decoupling owner
895
1122
  * status from the libp2p transport peer ID.
896
- */
897
- async getOwnerKeys() {
1123
+ *
1124
+ * Rows whose `StampId` is retired in `CadreControl.Revocation` are excluded, as
1125
+ * {@link queryCadrePeers} excludes them: `OwnerKey` is never reaped (`MinOneOwner` makes
1126
+ * an automated reap able to empty the table), so a node that received the tombstone
1127
+ * without the delete (a removal committed while the remover was alone, re-issued on growth)
1128
+ * keeps the removed owner's physical row next to it. Without the filter a removed owner
1129
+ * would still count as an owner in every membership snapshot and seed-trust read, still
1130
+ * read as the issuer of a live invitation and still be preferred as a dial target. The
1131
+ * write side filters for itself: every owner lookup in the schema disqualifies a row whose
1132
+ * stamp is retired (`schemas/control.qsql` header), so a removed owner cannot sign here
1133
+ * once its tombstone has arrived, row or no row.
1134
+ *
1135
+ * `retry: false` from a caller that reads under the write lock, for the reason on
1136
+ * {@link queryCadrePeers}.
1137
+ */
1138
+ async getOwnerKeys(retry = true) {
1139
+ return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
1140
+ }
1141
+ /**
1142
+ * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1143
+ * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1144
+ * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1145
+ * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1146
+ * reads through this. A row with no stamp (the schema permits it; no writer produces one)
1147
+ * is dropped too: no proof can bind to it, so no reader could ever derive it.
1148
+ *
1149
+ * `retry: false` from the membership-gate refresh, which runs as the control database's
1150
+ * membership listener with that database's write lock held — the reason on
1151
+ * {@link queryCadrePeers}; both reads here take the flag.
1152
+ *
1153
+ * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1154
+ * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1155
+ * signers); omitted, they are read here. Before the ledger marker is filed every
1156
+ * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1157
+ * membership refresh at one such read for the anchor.
1158
+ */
1159
+ async queryOwnerKeyRows(retry = true, retired) {
898
1160
  this.ensureInitialized();
899
- const keys = new Set();
900
- for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
901
- keys.add(row.Key);
1161
+ const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1162
+ const rows = [];
1163
+ const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1164
+ for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1165
+ const stampId = row.StampId ?? null;
1166
+ if (stampId === null) {
1167
+ log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
1168
+ continue;
1169
+ }
1170
+ if (revoked.has(stampId)) {
1171
+ continue;
1172
+ }
1173
+ rows.push({
1174
+ key: row.Key,
1175
+ stampId,
1176
+ vouchOwner: row.VouchOwner ?? null,
1177
+ vouchSig: row.VouchSig ?? null,
1178
+ vouchUsage: row.VouchUsage ?? null,
1179
+ });
902
1180
  }
903
- return keys;
1181
+ return rows;
904
1182
  }
905
1183
  /**
906
1184
  * Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
@@ -942,7 +1220,7 @@ export class ControlDatabase {
942
1220
  this.ensureInitialized();
943
1221
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
944
1222
  const rows = [];
945
- for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
1223
+ for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
946
1224
  const stampId = row.StampId ?? null;
947
1225
  if (stampId !== null && revoked.has(stampId)) {
948
1226
  continue;
@@ -953,10 +1231,24 @@ export class ControlDatabase {
953
1231
  stampId,
954
1232
  vouchOwner: row.VouchOwner ?? null,
955
1233
  vouchSig: row.VouchSig ?? null,
1234
+ vouchUsage: row.VouchUsage ?? null,
956
1235
  });
957
1236
  }
958
1237
  return rows;
959
1238
  }
1239
+ /**
1240
+ * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
1241
+ * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
1242
+ * the replicated `OwnerKey` table ({@link projectSeedPeers}).
1243
+ *
1244
+ * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1245
+ * member's addresses are never packed into a seed or handed to a redeeming device as a
1246
+ * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1247
+ * its exits.
1248
+ */
1249
+ async querySeedPeers() {
1250
+ return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
1251
+ }
960
1252
  /**
961
1253
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
962
1254
  * set a `ValidationUrl` redemption's approval signature must be signed by (see
@@ -1136,22 +1428,26 @@ export class ControlDatabase {
1136
1428
  }
1137
1429
  }
1138
1430
  /**
1139
- * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
1140
- * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
1141
- * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
1142
- * it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
1143
- * the composite-primary-key point-lookup hazard (see the statement comment in
1431
+ * Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
1432
+ * `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
1433
+ * sweep, which enumerates what this node holds before {@link reissueRevocations}
1434
+ * re-broadcasts it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no
1435
+ * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1144
1436
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1145
1437
  *
1146
1438
  * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1147
1439
  * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1148
1440
  * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1149
1441
  * admits no other row under `'Revocation'`.
1442
+ *
1443
+ * `retry: false` from a caller that reads under the write lock, for the reason on
1444
+ * {@link queryCadrePeers}.
1150
1445
  */
1151
- async queryRevocations() {
1446
+ async queryRevocations(retry = true) {
1152
1447
  this.ensureInitialized();
1153
1448
  const rows = [];
1154
- for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
1449
+ const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
1450
+ for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
1155
1451
  if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1156
1452
  continue;
1157
1453
  }
@@ -1160,6 +1456,8 @@ export class ControlDatabase {
1160
1456
  rowKey: row.RowKey,
1161
1457
  stampId: row.StampId,
1162
1458
  reissuedAt: row.ReissuedAt ?? 0,
1459
+ signerKey: row.SignerKey,
1460
+ signerSig: row.SignerSig,
1163
1461
  });
1164
1462
  }
1165
1463
  return rows;
@@ -1291,12 +1589,12 @@ export class ControlDatabase {
1291
1589
  log('Inserting owner key: %s', key);
1292
1590
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
1293
1591
  // committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
1294
- // signature is needed. Every other branch of `OwnerKey.Authorized` requires a signature
1295
- // from a PRE-EXISTING owner, so this method only ever succeeds on a fresh party; seating
1296
- // a second owner (or removing one) has no writer here and must sign the digests
1297
- // documented on the schema's `Authorized` constraint. We still persist a fresh, unique
1298
- // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
1299
- // the StampId is a real column value, not the optimystic `StampId()` SQL function.
1592
+ // signature is needed. Every other branch of `OwnerKey.Authorized` requires a PRE-EXISTING,
1593
+ // unretired owner, so this method only ever succeeds on a fresh party; a second
1594
+ // owner is seated by {@link insertOwnerKeyVouched} and removed by {@link deleteOwnerKey}.
1595
+ // We still persist a fresh, unique StampId in the row's own column to satisfy the
1596
+ // not-null/unique anti-replay constraint — the StampId is a real column value, not the
1597
+ // optimystic `StampId()` SQL function.
1300
1598
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1301
1599
  await this.execWrite(`
1302
1600
  insert into CadreControl.OwnerKey (Key, StampId)
@@ -1305,6 +1603,88 @@ export class ControlDatabase {
1305
1603
  `, [key, stampId], 'owner-key-insert');
1306
1604
  log('Owner key inserted');
1307
1605
  }
1606
+ /**
1607
+ * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
1608
+ * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
1609
+ * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
1610
+ * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
1611
+ * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
1612
+ *
1613
+ * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
1614
+ * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
1615
+ * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
1616
+ *
1617
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
1618
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
1619
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
1620
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
1621
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
1622
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
1623
+ *
1624
+ * @returns `true` when this call seated the row, `false` when `key` is already a live owner
1625
+ * here (nothing written).
1626
+ * @throws when this node holds `key`'s row under a retired stamp (above), or when the
1627
+ * schema refuses the signer.
1628
+ */
1629
+ async insertOwnerKeyVouched(key, ownerKey, signMessage) {
1630
+ this.ensureInitialized();
1631
+ return await this.mutateCadrePeer('owner-key-add', async () => {
1632
+ // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1633
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
1634
+ if (heldStamp !== null) {
1635
+ if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
1636
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
1637
+ return false;
1638
+ }
1639
+ throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
1640
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it');
1641
+ }
1642
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1643
+ const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
1644
+ // Bare `exec`: already inside the write lock, which is NOT re-entrant.
1645
+ await this.db.exec(`
1646
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1647
+ with context OwnerKey = ?, Signature = ?
1648
+ values (?, ?, ?, ?)
1649
+ `, [ownerKey, signature, key, stampId, ownerKey, signature]);
1650
+ log('Owner key %s added (vouched by %s)', key, ownerKey);
1651
+ return true;
1652
+ });
1653
+ }
1654
+ /**
1655
+ * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
1656
+ * stamp, notifying the membership listener once it has committed. The remove branch of
1657
+ * `OwnerKey.Authorized` requires a signer other than the removed key, and `MinOneOwner`
1658
+ * keeps the table from emptying; both are refused by name here first, read from this
1659
+ * node's live owners ({@link getOwnerKeys}), so a caller gets a clear error rather than a
1660
+ * constraint failure. The schema stays the authority: a concurrent removal elsewhere can
1661
+ * make the local count wrong in either direction, and then `MinOneOwner` or the tombstone's
1662
+ * primary key decides (see the NOTE on {@link deleteGuardedRow}).
1663
+ *
1664
+ * Through {@link mutateCadrePeer}, the membership hub, which notifies whenever the body
1665
+ * resolves, the absent-key answer included.
1666
+ *
1667
+ * @returns `true` when the row was removed, `false` when `key` is not a live owner here
1668
+ * (nothing written, no tombstone).
1669
+ * @throws when `key` is `ownerKey`, or is the only live owner.
1670
+ */
1671
+ async deleteOwnerKey(key, ownerKey, signMessage) {
1672
+ if (key === ownerKey) {
1673
+ throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
1674
+ }
1675
+ return await this.mutateCadrePeer('owner-key-remove', async () => {
1676
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
1677
+ const owners = await this.getOwnerKeys(false);
1678
+ if (!owners.has(key)) {
1679
+ log('deleteOwnerKey: %s is not a live owner here (already absent)', key);
1680
+ return false;
1681
+ }
1682
+ if (owners.size <= 1) {
1683
+ throw new Error(`Cannot remove owner key ${key}: cannot remove the last owner`);
1684
+ }
1685
+ return await this.deleteGuardedRow('OwnerKey', key, ownerKey, signMessage);
1686
+ });
1687
+ }
1308
1688
  /**
1309
1689
  * Insert a strand into the control database using an owner signature.
1310
1690
  *
@@ -1334,7 +1714,7 @@ export class ControlDatabase {
1334
1714
  // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
1335
1715
  // deliberately NOT in the digest — the schema binds it by equality to the
1336
1716
  // verified context.OwnerKey instead (see the constraint's comment).
1337
- const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
1717
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
1338
1718
  const signature = signMessage(message);
1339
1719
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
1340
1720
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
@@ -1396,10 +1776,10 @@ export class ControlDatabase {
1396
1776
  return false;
1397
1777
  }
1398
1778
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1399
- const strandRemoval = signGuardedRemoval('Strand', strandId, strandStamp, signMessage);
1779
+ const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
1400
1780
  const partyKeyRemoval = partyKeyStamp === null
1401
1781
  ? null
1402
- : signGuardedRemoval('StrandPartyKey', strandId, partyKeyStamp, signMessage);
1782
+ : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
1403
1783
  await this.inTransaction('delete Strand', async () => {
1404
1784
  await this.execGuardedRemoval(strandRemoval, ownerKey);
1405
1785
  if (partyKeyRemoval !== null) {
@@ -1436,7 +1816,7 @@ export class ControlDatabase {
1436
1816
  const stampId = generateStampId(peerId);
1437
1817
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
1438
1818
  // Id, PrivateKey, StampId.
1439
- const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
1819
+ const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
1440
1820
  const signature = signMessage(message);
1441
1821
  await this.execWrite(`
1442
1822
  insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
@@ -1478,7 +1858,7 @@ export class ControlDatabase {
1478
1858
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1479
1859
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
1480
1860
  // Id, Type, MemberPrivateKey ('' when null), StampId.
1481
- const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
1861
+ const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
1482
1862
  const signature = signMessage(message);
1483
1863
  await this.execWrite(`
1484
1864
  insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
@@ -1497,76 +1877,124 @@ export class ControlDatabase {
1497
1877
  return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
1498
1878
  }
1499
1879
  /**
1500
- * Record, party-wide, a join this party asked for (`PendingJoin` row), using an owner
1501
- * signature over every column of the row (see {@link pendingJoinAddFields}). Returns the
1502
- * row as written, with the fresh `StampId` a later {@link replacePendingJoin} names.
1880
+ * Record, party-wide, a join this party asked for: a `JoinRequest` row under a fresh stamp,
1881
+ * owner-signed over every column. Returns the pending join as written.
1503
1882
  *
1504
- * Fails with a `PendingJoin.Id` uniqueness violation when the party already holds a row for
1505
- * this invitation; {@link isPendingJoinConflict} identifies that case.
1883
+ * Fails with a `JoinRequest.Id` uniqueness violation when the party already holds a request
1884
+ * for this invitation; {@link isPendingJoinConflict} identifies that case.
1506
1885
  */
1507
- async insertPendingJoin(row, ownerKey, signMessage) {
1886
+ async insertJoinRequest(request, ownerKey, signMessage) {
1508
1887
  this.ensureInitialized();
1509
- log('Inserting pending join: %s (outcome: %s)', row.Id, row.Outcome);
1510
- const { written, params } = this.signPendingJoinInsert(row, ownerKey, signMessage);
1511
- await this.execWrite(PENDING_JOIN_INSERT_SQL, params, 'pending-join-insert');
1512
- log('Pending join inserted: %s', row.Id);
1513
- return written;
1888
+ const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
1889
+ await this.execWrite(insert.sql, insert.params, 'join-request-insert');
1890
+ log('Join request inserted: %s', request.Id);
1891
+ return { ...written, outcome: null };
1514
1892
  }
1515
1893
  /**
1516
- * Replace the live `PendingJoin` row `next.Id` with `next`, under a fresh stamp — the only way
1517
- * an outcome is written, since the table refuses updates. Returns the row as written.
1894
+ * Record the outcome of the pending join `expected`: its owner-signed `JoinSuccess` /
1895
+ * `JoinFailure` row, keyed by the request's stamp. A join recorded over a failure deletes the
1896
+ * failure row in the same transaction (the schema authorizes that delete by the join, and
1897
+ * leaves the one-outcome rule in that direction to this writer). Returns the pending join as
1898
+ * written.
1518
1899
  *
1519
- * One locked transaction deletes the row (owner-signed `'remove'` digest, with its
1520
- * `Revocation` tombstone) and inserts its successor, so no reader ever sees the id missing in
1521
- * between. The live row must still be the incarnation the caller read: when its `StampId` is
1522
- * not `expectedStampId`, or the row is gone, this throws {@link PendingJoinChangedError} and
1523
- * writes nothing. That check is local; a sibling machine's replacement or removal that lands
1524
- * between it and the commit fails the transaction instead (its tombstone for the same stamp,
1525
- * or its successor row, collides on a primary key), and the caller re-reads either way.
1900
+ * The live join must still be what the caller read: the same request incarnation with the
1901
+ * same outcome kind. Otherwise this throws {@link PendingJoinChangedError} and writes nothing.
1902
+ * That check is local; a sibling machine's write landing between it and the commit fails the
1903
+ * transaction instead (its outcome row collides on the primary key, or its tombstone retires
1904
+ * the stamp), and the caller re-reads either way.
1905
+ */
1906
+ async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
1907
+ this.ensureInitialized();
1908
+ const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
1909
+ return this.lockedWithRetry(async () => {
1910
+ const live = await this.readPendingJoin(expected.Id, false);
1911
+ if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
1912
+ throw new PendingJoinChangedError(expected.Id, live);
1913
+ }
1914
+ await this.inTransaction('record join outcome', async () => {
1915
+ if (live.outcome?.kind === 'failed') {
1916
+ await this.deleteJoinOutcome('JoinFailure', expected.StampId);
1917
+ }
1918
+ await this.db.exec(insert.sql, insert.params);
1919
+ });
1920
+ log('Join outcome recorded: %s (%s)', expected.Id, outcome.kind);
1921
+ return { ...live, outcome };
1922
+ }, {}, 'join-outcome-record');
1923
+ }
1924
+ /**
1925
+ * Re-write the pending join `next` (request and outcome) under a fresh stamp, retiring the
1926
+ * live request incarnation `expectedStampId`: how a finished request is asked for again, and
1927
+ * how a join written while this machine was alone is re-broadcast. One locked transaction
1928
+ * deletes the outcome row and the request (owner-signed `'remove'` digest, with its
1929
+ * `Revocation` tombstone) and inserts the successors, so no reader ever sees the id missing in
1930
+ * between. Throws {@link PendingJoinChangedError} and writes nothing when the live request is
1931
+ * not `expectedStampId`.
1526
1932
  *
1527
1933
  * Signatures and the new stamp are minted before the lock, so a retried attempt re-presents
1528
1934
  * the same signed messages (the contract on {@link withWriteLock}). A retry after an attempt
1529
- * that committed but reported failure throws {@link PendingJoinChangedError} whose
1530
- * `liveStampId` is the returned row's own stamp, so a caller that re-reads finds its write.
1935
+ * that committed but reported failure throws {@link PendingJoinChangedError} whose live join
1936
+ * carries the returned stamp, so a caller that re-reads finds its write.
1531
1937
  */
1532
- async replacePendingJoin(expectedStampId, next, ownerKey, signMessage) {
1938
+ async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
1533
1939
  this.ensureInitialized();
1534
- const id = next.Id;
1535
- const removal = signGuardedRemoval('PendingJoin', id, expectedStampId, signMessage);
1536
- const { written, params } = this.signPendingJoinInsert(next, ownerKey, signMessage);
1940
+ const { outcome, ...request } = next;
1941
+ const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
1942
+ const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
1943
+ const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
1537
1944
  return this.lockedWithRetry(async () => {
1538
1945
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1539
- const liveStampId = await this.queryStampId('PendingJoin', id, false);
1946
+ const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
1540
1947
  if (liveStampId !== expectedStampId) {
1541
- throw new PendingJoinChangedError(id, expectedStampId, liveStampId);
1948
+ throw new PendingJoinChangedError(next.Id, await this.readPendingJoin(next.Id, false));
1542
1949
  }
1543
- await this.inTransaction('replace PendingJoin', async () => {
1950
+ await this.inTransaction('rewrite pending join', async () => {
1951
+ await this.deleteJoinOutcomes(expectedStampId);
1544
1952
  await this.execGuardedRemoval(removal, ownerKey);
1545
- await this.db.exec(PENDING_JOIN_INSERT_SQL, params);
1953
+ await this.db.exec(insert.sql, insert.params);
1954
+ if (outcomeInsert !== null) {
1955
+ await this.db.exec(outcomeInsert.sql, outcomeInsert.params);
1956
+ }
1546
1957
  });
1547
- log('Pending join replaced: %s (outcome: %s; stamp retired)', id, next.Outcome);
1958
+ log('Pending join rewritten: %s (%s; stamp retired)', next.Id, outcome?.kind ?? 'pending');
1548
1959
  this.notifyGuardedDelete(removal.ref);
1549
- return written;
1550
- }, {}, 'pending-join-replace');
1960
+ return { ...written, outcome };
1961
+ }, {}, 'pending-join-rewrite');
1551
1962
  }
1552
1963
  /**
1553
- * Owner-signed removal of one `PendingJoin` row, as {@link deleteJoinedStrand} removes its
1554
- * row: `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same
1555
- * transaction. A no-op (no throw, no tombstone) when the row does not exist — `false` then,
1556
- * `true` when a row was actually removed.
1964
+ * Owner-signed removal of one pending join, as {@link deleteJoinedStrand} removes its row:
1965
+ * `'remove'`-tagged digest over (Id, StampId), `Revocation` tombstone in the same transaction,
1966
+ * and the request's outcome row with it. A no-op (no throw, no tombstone) when there is no
1967
+ * request — `false` then, `true` when one was actually removed.
1557
1968
  */
1558
1969
  deletePendingJoin(id, ownerKey, signMessage) {
1559
- return this.lockedWithRetry(() => this.deleteGuardedRow('PendingJoin', id, ownerKey, signMessage), {}, 'pending-join-delete');
1970
+ return this.lockedWithRetry(() => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)), {}, 'pending-join-delete');
1560
1971
  }
1561
1972
  /**
1562
- * Mint a fresh stamp for `row` and the owner signature `PendingJoin.AuthorizedInsert`
1563
- * verifies, returning the row as it will be written and the parameters of
1564
- * {@link PENDING_JOIN_INSERT_SQL}.
1973
+ * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
1974
+ * verifies, returning the row as it will be written and its insert.
1565
1975
  */
1566
- signPendingJoinInsert(row, ownerKey, signMessage) {
1567
- const written = { ...row, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
1568
- const signature = signMessage(buildAuthorizationMessage('CadreControl.PendingJoin', 'add', pendingJoinAddFields(written)));
1569
- return { written, params: [ownerKey, signature, ...pendingJoinValues(written)] };
1976
+ signJoinRequestInsert(request, ownerKey, signMessage) {
1977
+ const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
1978
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
1979
+ const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
1980
+ return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
1981
+ }
1982
+ /**
1983
+ * Remove the outcome row of the request incarnation `requestStampId`, whichever table holds
1984
+ * it. Unsigned: the schema authorizes the delete by the tombstone retiring that stamp, filed
1985
+ * in the same transaction or already committed (a reap).
1986
+ */
1987
+ async deleteJoinOutcomes(requestStampId) {
1988
+ await this.deleteJoinOutcome('JoinSuccess', requestStampId);
1989
+ await this.deleteJoinOutcome('JoinFailure', requestStampId);
1990
+ }
1991
+ /** One unsigned outcome delete (see {@link deleteJoinOutcomes}; a failure's is also authorized by the join superseding it). */
1992
+ async deleteJoinOutcome(table, requestStampId) {
1993
+ await this.db.exec(`
1994
+ delete from CadreControl.${table}
1995
+ with context OwnerKey = null, Signature = null
1996
+ where RequestStampId = ?
1997
+ `, [requestStampId]);
1570
1998
  }
1571
1999
  /**
1572
2000
  * Insert a validation key into the control database using an owner signature.
@@ -1587,7 +2015,7 @@ export class ControlDatabase {
1587
2015
  const peerId = this.config.libp2pNode.peerId.toString();
1588
2016
  const stampId = generateStampId(peerId);
1589
2017
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
1590
- const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
2018
+ const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
1591
2019
  const signature = signMessage(message);
1592
2020
  await this.execWrite(`
1593
2021
  insert into CadreControl.ValidationKey (Key, StampId)
@@ -1657,7 +2085,7 @@ export class ControlDatabase {
1657
2085
  // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
1658
2086
  // row it vouches.
1659
2087
  const stampId = generateStampId(row.peerId);
1660
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
2088
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
1661
2089
  return await this.mutateCadrePeer('peer-insert', async () => {
1662
2090
  // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
1663
2091
  if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
@@ -1710,6 +2138,11 @@ export class ControlDatabase {
1710
2138
  * change the member set. Keeping the rule uniform ("every CadrePeer mutator notifies")
1711
2139
  * beats a per-method exception the next reader has to relearn.
1712
2140
  *
2141
+ * Also clears `VouchUsage`: a row carries exactly one proof (the owner branch of
2142
+ * `AuthorizedUpdate` requires it null), so re-vouching an invitation-admitted row moves it
2143
+ * onto the signature and its invitation is no longer consulted for it — the way an owner
2144
+ * retires a device's dependency on an invitation it may later withdraw.
2145
+ *
1713
2146
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
1714
2147
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
1715
2148
  * reader's node-local anchor. Benign today because the only caller — the write-while-alone
@@ -1733,13 +2166,13 @@ export class ControlDatabase {
1733
2166
  log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
1734
2167
  return false;
1735
2168
  }
1736
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
2169
+ const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
1737
2170
  await this.mutateCadrePeer('peer-reauthorize', async () => {
1738
2171
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1739
2172
  await this.db.exec(`
1740
2173
  update CadreControl.CadrePeer
1741
2174
  with context OwnerKey = ?, Signature = ?
1742
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
2175
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
1743
2176
  where PeerId = ?
1744
2177
  `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
1745
2178
  });
@@ -1788,15 +2221,9 @@ export class ControlDatabase {
1788
2221
  * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
1789
2222
  * stamp, in ONE transaction. The single body behind EVERY guarded delete —
1790
2223
  * {@link deleteStrand}, {@link deleteValidationKey}, {@link deleteCadrePeer},
1791
- * {@link deleteDeviceToken}; see any of them for the per-table security rationale. Each
1792
- * of those is a thin named wrapper, so callers never pass a table name and this
1793
- * generic shape stays off the public surface.
1794
- *
1795
- * `OwnerKey` is excluded from `table` only because no owner-key removal path exists in
1796
- * production yet — the schema has the `'remove'` branch (`OwnerKey.Authorized`, which
1797
- * requires a DIFFERENT owner as signer) and `control-revocation-replay.spec.ts` drives
1798
- * it with hand-rolled SQL. Widen this and add a wrapper when that path lands; the body
1799
- * needs no change.
2224
+ * {@link deleteDeviceToken}, {@link deleteOwnerKey}; see any of them for the per-table
2225
+ * security rationale. Each of those is a thin named wrapper, so callers never pass a
2226
+ * table name and this generic shape stays off the public surface.
1800
2227
  *
1801
2228
  * The row's CURRENT stamp is read first and signed over, so the remove digest binds to
1802
2229
  * this exact row instance. A no-op (no throw, no tombstone) when the row is absent —
@@ -1804,6 +2231,9 @@ export class ControlDatabase {
1804
2231
  * second read (e.g. {@link CadreNode.unpublishStrand}'s committed-while-alone warning,
1805
2232
  * which must not fire for a no-op).
1806
2233
  *
2234
+ * `alongside`, when given, runs inside the transaction before the removal, for a table whose
2235
+ * row has dependants the schema requires removed with it ({@link deletePendingJoin}).
2236
+ *
1807
2237
  * NOTE: the stamp read is outside the transaction. A concurrent writer that removes
1808
2238
  * the row in between makes the signature bind a stamp that is no longer live; the
1809
2239
  * delete then matches nothing and the tombstone insert collides with the other
@@ -1814,7 +2244,7 @@ export class ControlDatabase {
1814
2244
  * Deliberately NOT wrapped in {@link withWriteLock}: every public entry point already
1815
2245
  * holds the (non-re-entrant) lock, so taking it again here would self-deadlock.
1816
2246
  */
1817
- async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
2247
+ async deleteGuardedRow(table, keyValue, ownerKey, signMessage, alongside) {
1818
2248
  this.ensureInitialized();
1819
2249
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1820
2250
  const stampId = await this.queryStampId(table, keyValue, false);
@@ -1822,8 +2252,11 @@ export class ControlDatabase {
1822
2252
  log('delete %s: no row for %s (already absent)', table, keyValue);
1823
2253
  return false;
1824
2254
  }
1825
- const removal = signGuardedRemoval(table, keyValue, stampId, signMessage);
1826
- await this.inTransaction(`delete ${table}`, () => this.execGuardedRemoval(removal, ownerKey));
2255
+ const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2256
+ await this.inTransaction(`delete ${table}`, async () => {
2257
+ await alongside?.(stampId);
2258
+ await this.execGuardedRemoval(removal, ownerKey);
2259
+ });
1827
2260
  log('%s deleted: %s (stamp retired)', table, keyValue);
1828
2261
  this.notifyGuardedDelete(removal.ref);
1829
2262
  return true;
@@ -1831,7 +2264,7 @@ export class ControlDatabase {
1831
2264
  /**
1832
2265
  * Delete the row incarnation `removal` names and file its tombstone — the statement pair
1833
2266
  * every owner removal of a guarded row runs ({@link deleteGuardedRow},
1834
- * {@link deleteStrandAndPartyKey}, {@link replacePendingJoin}). The caller supplies the
2267
+ * {@link deleteStrandAndPartyKey}, {@link rewritePendingJoin}). The caller supplies the
1835
2268
  * transaction: the schema's `RevocationRecorded` check refuses the delete unless the
1836
2269
  * tombstone commits with it.
1837
2270
  *
@@ -1848,14 +2281,28 @@ export class ControlDatabase {
1848
2281
  with context OwnerKey = ?, Signature = ?
1849
2282
  where ${GUARDED_KEY_COLUMN[tableName]} = ? and StampId = ?
1850
2283
  `, [ownerKey, removal.signature, rowKey, stampId]);
1851
- // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1852
- // than leaning on the column default — the seat-at-zero rule is load-bearing for
1853
- // reissueRevocations' monotonic bump, so state it at the write site.
2284
+ await this.execTombstone(removal.ref, removal.revocationSignature, ownerKey);
2285
+ }
2286
+ /**
2287
+ * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2288
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
2289
+ * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
2290
+ * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2291
+ * lock and, where one is needed, the transaction.
2292
+ *
2293
+ * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
2294
+ * than leaning on the column default — the seat-at-zero rule is load-bearing for
2295
+ * {@link reissueRevocations}' monotonic bump, so state it at the write site.
2296
+ *
2297
+ * `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
2298
+ * equal), so a node that receives the row by replication can tell which owner filed it.
2299
+ */
2300
+ async execTombstone(ref, revocationSignature, ownerKey) {
1854
2301
  await this.db.exec(`
1855
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
2302
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
1856
2303
  with context OwnerKey = ?, Signature = ?
1857
- values (?, ?, ?, 0)
1858
- `, [ownerKey, removal.revocationSignature, tableName, rowKey, stampId]);
2304
+ values (?, ?, ?, 0, ?, ?)
2305
+ `, [ownerKey, revocationSignature, ref.tableName, ref.rowKey, ref.stampId, ownerKey, revocationSignature]);
1859
2306
  }
1860
2307
  /**
1861
2308
  * Fire the guarded-delete listener for one committed tombstone, swallowing (and
@@ -1956,6 +2403,16 @@ export class ControlDatabase {
1956
2403
  // in one pass, hoist the notify to one refresh after the sweep instead.
1957
2404
  await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
1958
2405
  }
2406
+ else if (table === 'JoinRequest') {
2407
+ // The request's outcome row goes with it; the committed tombstone authorizes that delete
2408
+ // too. An outcome row held for a request this node never received (the request was
2409
+ // written alone and its outcome connected, before the growth-edge re-issue) is left: it
2410
+ // is unreadable, since every reader starts from the request.
2411
+ await this.lockedWithRetry(() => this.inTransaction('reap JoinRequest', async () => {
2412
+ await this.deleteJoinOutcomes(stampId);
2413
+ await this.db.exec(sql, [rowKey, stampId]);
2414
+ }), {}, 'reap-JoinRequest');
2415
+ }
1959
2416
  else {
1960
2417
  await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
1961
2418
  }
@@ -1994,7 +2451,7 @@ export class ControlDatabase {
1994
2451
  * reads from this node (every other node already filters it by tombstone). A revoked
1995
2452
  * node therefore keeps its own copy of its own row; "a node that learns it has been
1996
2453
  * revoked should shut itself down" is a distinct behaviour and its own ticket.
1997
- * `ValidationKey`, `JoinedStrand` and `PendingJoin` have no self notion and are not
2454
+ * `ValidationKey`, `JoinedStrand` and `JoinRequest` have no self notion and are not
1998
2455
  * special-cased.
1999
2456
  *
2000
2457
  * A per-row failure is logged and skipped rather than aborting the sweep: an abort would
@@ -2083,7 +2540,7 @@ export class ControlDatabase {
2083
2540
  // and every digest field is TEXT on both sides (see buildAuthorizationMessage).
2084
2541
  const signed = rows.map((row) => ({
2085
2542
  row,
2086
- signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2543
+ signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2087
2544
  }));
2088
2545
  return this.lockedWithRetry(async () => {
2089
2546
  // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
@@ -2149,19 +2606,14 @@ export class ControlDatabase {
2149
2606
  */
2150
2607
  async openRevocationLedger(ownerKey, signMessage) {
2151
2608
  this.ensureInitialized();
2152
- const { tableName, rowKey, stampId } = REVOCATION_LEDGER_MARKER;
2153
- const signature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]));
2609
+ const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2154
2610
  try {
2155
2611
  return await this.lockedWithRetry(async () => {
2156
2612
  if (await this.revocationLedgerFiled()) {
2157
2613
  return 'already-open';
2158
2614
  }
2159
- // Bare `exec`: already inside the write lock, which is NOT re-entrant.
2160
- await this.db.exec(`
2161
- insert into CadreControl.Revocation (TableName, RowKey, StampId)
2162
- with context OwnerKey = ?, Signature = ?
2163
- values (?, ?, ?)
2164
- `, [ownerKey, signature, tableName, rowKey, stampId]);
2615
+ // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
2616
+ await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
2165
2617
  log('Revocation ledger marker filed');
2166
2618
  return 'opened';
2167
2619
  }, {}, 'revocation-ledger-open');
@@ -2258,15 +2710,18 @@ export class ControlDatabase {
2258
2710
  this.controlWriteAbandonedListener = listener;
2259
2711
  }
2260
2712
  /**
2261
- * Run a `CadrePeer` row mutation and notify the membership listener once it has
2713
+ * Run a membership-relevant mutation and notify the membership listener once it has
2262
2714
  * COMMITTED.
2263
2715
  *
2264
- * EVERY `CadrePeer` writer goes through here, with one documented exception
2265
- * ({@link updateSelfPeerRecord}, which cannot change the snapshot) — that is what makes
2266
- * the party-membership snapshot refresh automatic rather than a caller obligation. A
2716
+ * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
2717
+ * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
2718
+ * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
2719
+ * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
2720
+ * which seats both. That is what makes the party-membership snapshot refresh automatic
2721
+ * rather than a caller obligation. The name predates the `OwnerKey` writers. A
2267
2722
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
2268
- * event callbacks are NOT a viable seam: the temp services `CadreNode.applySeed`/
2269
- * `dialInvite` build, and services constructed outside `CadreNode` entirely, never get
2723
+ * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
2724
+ * builds, and services constructed outside `CadreNode` entirely, never get
2270
2725
  * callbacks wired), so this is the one point on the write path that cannot be bypassed.
2271
2726
  *
2272
2727
  * `body` owns its own transaction if it needs one and must COMMIT before returning, so
@@ -2447,7 +2902,7 @@ export class ControlDatabase {
2447
2902
  return;
2448
2903
  }
2449
2904
  throw new Error(`mutateCadrePeer(${reason}): a transaction is open ${where} the mutation body — a ` +
2450
- 'CadrePeer write must commit before the membership listener runs; move the ' +
2905
+ 'membership write must commit before the membership listener runs; move the ' +
2451
2906
  'mutateCadrePeer wrapper out to enclose the commit');
2452
2907
  }
2453
2908
  /**
@@ -2526,7 +2981,7 @@ export class ControlDatabase {
2526
2981
  const strandIdField = options.strandId ?? '';
2527
2982
  // Field order MUST match the schema's FormationInvite `AuthorizedInsert` verify:
2528
2983
  // Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId.
2529
- const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', [
2984
+ const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
2530
2985
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
2531
2986
  ]);
2532
2987
  const signature = signMessage(message);
@@ -2860,6 +3315,389 @@ export class ControlDatabase {
2860
3315
  }
2861
3316
  return false;
2862
3317
  }
3318
+ // ── Cadre invitations (CadreInvite / CadreInviteUsage) ─────────────────────────────────
3319
+ /**
3320
+ * Mint and insert an owner-signed `CadreInvite`: an invitation to join THIS cadre that any
3321
+ * member machine can later redeem on the owner's behalf ({@link redeemCadreInvite}).
3322
+ *
3323
+ * The owner signs the whole row ({@link cadreInviteAddMessage}) — `ExpiresAt` through
3324
+ * {@link canonicalDatetime} so the signed segment byte-matches the stored `datetime`, as
3325
+ * {@link insertFormationInvite} does — and the stored `IssuerKey`/`IssuerSig` are pinned to
3326
+ * that signer by `CadreInvite.AuthorizedInsert`. The signature is part of the row because
3327
+ * the row travels: the returned {@link CadreInviteRow} is what the invitation bundle
3328
+ * carries to the holder, and {@link seatCadreInvite} re-inserts it verbatim on a member
3329
+ * that has not received it by replication.
3330
+ *
3331
+ * `key` is the invitation's ed25519 PUBLIC key; the caller minted the pair and hands the
3332
+ * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
3333
+ * same shape).
3334
+ *
3335
+ * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
3336
+ * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
3337
+ * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
3338
+ * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
3339
+ * @param invite.totalUses - how many redemptions it allows; omit for unlimited
3340
+ * @returns the row as stored, `expiresAt` in its canonical form
3341
+ */
3342
+ async insertCadreInvite(invite, ownerKey, signMessage) {
3343
+ this.ensureInitialized();
3344
+ const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
3345
+ const signed = {
3346
+ key,
3347
+ peerId: invite.peerId ?? null,
3348
+ grantsOwner: invite.grantsOwner,
3349
+ expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
3350
+ totalUses: invite.totalUses ?? null,
3351
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
3352
+ };
3353
+ const row = {
3354
+ ...signed,
3355
+ issuerKey: ownerKey,
3356
+ issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
3357
+ };
3358
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
3359
+ log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
3360
+ return row;
3361
+ }
3362
+ /**
3363
+ * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
3364
+ * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
3365
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
3366
+ *
3367
+ * @returns `true` when this call inserted the row, `false` when it was already present.
3368
+ * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
3369
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
3370
+ * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3371
+ */
3372
+ async seatCadreInvite(row) {
3373
+ this.ensureInitialized();
3374
+ try {
3375
+ return await this.lockedWithRetry(async () => {
3376
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
3377
+ if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
3378
+ log('CadreInvite %s already seated; seat skipped', row.key);
3379
+ return false;
3380
+ }
3381
+ await this.execCadreInviteInsert(row);
3382
+ log('Cadre invitation seated from its bundle: %s', row.key);
3383
+ return true;
3384
+ }, {}, 'cadre-invite-seat');
3385
+ }
3386
+ catch (error) {
3387
+ if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
3388
+ throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
3389
+ }
3390
+ throw error;
3391
+ }
3392
+ }
3393
+ /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
3394
+ async execCadreInviteInsert(row) {
3395
+ await this.db.exec(`
3396
+ insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
3397
+ with context OwnerKey = ?, Signature = ?
3398
+ values (?, ?, ?, ?, ?, ?, ?, ?)
3399
+ `, [
3400
+ row.issuerKey, row.issuerSig,
3401
+ row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
3402
+ row.issuerKey, row.issuerSig, row.stampId,
3403
+ ]);
3404
+ }
3405
+ /**
3406
+ * Read one `CadreInvite` row by key, or null when absent. Withdrawn rows are returned too —
3407
+ * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
3408
+ * question, and `CadreInviteUsage.Authorized`'s at redemption.
3409
+ *
3410
+ * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
3411
+ * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
3412
+ */
3413
+ async queryCadreInvite(key, retry = true) {
3414
+ this.ensureInitialized();
3415
+ for (const row of await this.readRows(`${CADRE_INVITE_SELECT_SQL} where Key = ?`, [key], 'cadre-invite', retry)) {
3416
+ return cadreInviteRowOf(row);
3417
+ }
3418
+ return null;
3419
+ }
3420
+ /**
3421
+ * Every `CadreInvite` row this node holds, withdrawn and expired ones included (see
3422
+ * {@link queryCadreInvite}). Read by the membership predicate when at least one `CadrePeer`
3423
+ * row is invitation-admitted; `retry: false` is forwarded from
3424
+ * {@link CadreNode.listAuthorizedMembers} on the membership-gate refresh path for
3425
+ * {@link queryCadrePeers}' reason.
3426
+ */
3427
+ async queryCadreInvites(retry = true) {
3428
+ this.ensureInitialized();
3429
+ const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
3430
+ return rows.map(cadreInviteRowOf);
3431
+ }
3432
+ /**
3433
+ * Every `CadreInviteUsage` row this node holds — the redemption records the membership
3434
+ * predicate verifies invitation-admitted rows against. Same `retry` contract as
3435
+ * {@link queryCadreInvites}.
3436
+ */
3437
+ async queryCadreInviteUsages(retry = true) {
3438
+ this.ensureInitialized();
3439
+ const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
3440
+ return rows.map(cadreInviteUsageRowOf);
3441
+ }
3442
+ /**
3443
+ * How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
3444
+ * through the `CadreInviteUsageByInvite` index. As {@link countFormationUsage}: a permissive
3445
+ * PRE-check, not the cap — the cap is `CadreInviteUsage.Authorized`'s own committed count.
3446
+ *
3447
+ * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
3448
+ */
3449
+ async countCadreInviteUsage(key, retry = true) {
3450
+ this.ensureInitialized();
3451
+ const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
3452
+ for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
3453
+ return row.Count ?? 0;
3454
+ }
3455
+ return 0;
3456
+ }
3457
+ /**
3458
+ * Is any cadre invitation still redeemable — not withdrawn, unexpired, with uses below its
3459
+ * `TotalUses`, and issued by a current owner? The conditions `CadreInviteUsage.Authorized`
3460
+ * applies, read ahead of time so the control-network connection gate can answer "does this
3461
+ * node expect a stranger?" with no key to ask about, exactly as
3462
+ * {@link hasOutstandingFormationInvite} answers it for strand formation. The expiry
3463
+ * comparison is `expiresAtMs <= now`, so an invitation the redemption would refuse never
3464
+ * holds the gate open.
3465
+ *
3466
+ * Withdrawal is read through {@link queryRevokedStamps}, the per-table retired-stamp seam
3467
+ * every other revocation-aware reader uses (and the one an isolated node can still answer).
3468
+ */
3469
+ async hasLiveCadreInvite(nowMs = Date.now()) {
3470
+ this.ensureInitialized();
3471
+ // NOTE: getOwnerKeys reads the retired OwnerKey stamps first, so this gate path scans
3472
+ // Revocation twice (once per table, the two in parallel). If the stranger path's decision
3473
+ // ever nears the admission gate's 2 s deadline, read the retired stamps once for both.
3474
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
3475
+ // NOTE: scans every CadreInvite row (expired and withdrawn ones included, since rows are
3476
+ // never deleted) on the stranger path of an inbound connection. Cadre-scale invitation
3477
+ // counts make that free today; if a long-lived cadre accumulates thousands and inbound
3478
+ // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
3479
+ // first needs every device they admitted re-vouched, so the chain no longer needs them).
3480
+ const metered = [];
3481
+ let unlimitedLive = false;
3482
+ for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
3483
+ const open = cadreInviteStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
3484
+ if (!open) {
3485
+ continue;
3486
+ }
3487
+ const totalUses = row.TotalUses ?? null;
3488
+ if (totalUses === null) {
3489
+ unlimitedLive = true;
3490
+ }
3491
+ else {
3492
+ metered.push({ key: row.Key, totalUses });
3493
+ }
3494
+ }
3495
+ if (unlimitedLive) {
3496
+ return true;
3497
+ }
3498
+ // NOTE: one retried read per metered invitation, each with its own read budget, on a path
3499
+ // the admission gate awaits under a 2 s fail-open deadline — the same shape and the same
3500
+ // revisit condition as hasOutstandingFormationInvite's loop.
3501
+ for (const invite of metered) {
3502
+ if (await this.countCadreInviteUsage(invite.key) < invite.totalUses) {
3503
+ return true;
3504
+ }
3505
+ }
3506
+ return false;
3507
+ }
3508
+ /**
3509
+ * Is this one cadre invitation still redeemable here — the conditions
3510
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
3511
+ * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
3512
+ * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
3513
+ * judged by whichever owner it names as issuer. A permissive pre-check like
3514
+ * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
3515
+ * what enforce every condition.
3516
+ */
3517
+ async isCadreInviteLive(invite, nowMs = Date.now()) {
3518
+ this.ensureInitialized();
3519
+ const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
3520
+ if (row === null) {
3521
+ return false;
3522
+ }
3523
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
3524
+ if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
3525
+ return false;
3526
+ }
3527
+ return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
3528
+ }
3529
+ /**
3530
+ * Every `CadreInvite` row this node holds with its standing: how many redemptions are
3531
+ * recorded, whether it is withdrawn, and whether it is still redeemable — the same
3532
+ * conditions {@link hasLiveCadreInvite} applies, answered per row for an owner's listing
3533
+ * rather than short-circuited. One usage count per row, so this is the owner's
3534
+ * occasional read, not a gate's.
3535
+ */
3536
+ async listCadreInviteStatuses(nowMs = Date.now()) {
3537
+ this.ensureInitialized();
3538
+ const [invites, withdrawnStamps, owners] = await Promise.all([
3539
+ this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
3540
+ ]);
3541
+ const statuses = [];
3542
+ // NOTE: one retried count read per invitation, withdrawn and expired rows included, since
3543
+ // rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
3544
+ // usage counts in one statement or skip the count for rows that are no longer open.
3545
+ for (const invite of invites) {
3546
+ const usesRecorded = await this.countCadreInviteUsage(invite.key);
3547
+ const withdrawn = withdrawnStamps.has(invite.stampId);
3548
+ const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
3549
+ && (invite.totalUses === null || usesRecorded < invite.totalUses);
3550
+ statuses.push({ invite, live, withdrawn, usesRecorded });
3551
+ }
3552
+ return statuses;
3553
+ }
3554
+ /**
3555
+ * Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
3556
+ * stamp, with the row kept. The row stays because every device the invitation admitted
3557
+ * proves its membership through it (`CadrePeer.AuthorizedInsert`'s consent branch,
3558
+ * `verifyInvitationAdmission`); `CadreInvite.NoDelete` refuses a delete, and
3559
+ * `Revocation.RowIsGone` admits a `'CadreInvite'` tombstone while the row lives. Afterwards
3560
+ * `CadreInviteUsage.Authorized` refuses every further redemption, and `CadreInvite.NotRevoked`
3561
+ * refuses re-seating the row from the holder's signed copy on any node holding the
3562
+ * tombstone.
3563
+ *
3564
+ * Idempotent: a no-op (no throw, nothing written) when the row is absent locally or already
3565
+ * withdrawn — `false` then, `true` when this call filed the tombstone. The owner must hold the
3566
+ * row to withdraw it, since the tombstone names the row's stamp.
3567
+ *
3568
+ * Fires the guarded-delete listener like a removal does, so a withdrawal committed while the
3569
+ * node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
3570
+ */
3571
+ async withdrawCadreInvite(key, ownerKey, signMessage) {
3572
+ this.ensureInitialized();
3573
+ const filed = await this.lockedWithRetry(async () => {
3574
+ // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
3575
+ const stampId = await this.queryStampId('CadreInvite', key, false);
3576
+ if (stampId === null) {
3577
+ log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
3578
+ return null;
3579
+ }
3580
+ if ((await this.queryRevokedStamps('CadreInvite', false)).has(stampId)) {
3581
+ log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
3582
+ return null;
3583
+ }
3584
+ const ref = { tableName: 'CadreInvite', rowKey: key, stampId };
3585
+ await this.execTombstone(ref, signMessage(revocationTombstoneMessage(this.config.partyId, ref)), ownerKey);
3586
+ return ref;
3587
+ }, {}, 'cadre-invite-withdraw');
3588
+ if (filed === null) {
3589
+ return false;
3590
+ }
3591
+ log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
3592
+ this.notifyGuardedDelete(filed);
3593
+ return true;
3594
+ }
3595
+ /**
3596
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
3597
+ * when the invitation grants ownership, as an owner — with NO owner signature. One
3598
+ * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
3599
+ * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
3600
+ * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
3601
+ * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
3602
+ * redemption and none can be held in reserve. The invitation must already be committed
3603
+ * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
3604
+ *
3605
+ * The two signatures are the holder's and the device's, minted on the device over
3606
+ * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
3607
+ * verifies them (by writing the row). `peerId` must be the identity multihash of `peerKey`:
3608
+ * the schema cannot derive it, so a writer asserts the pair and every reader re-derives it
3609
+ * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
3610
+ *
3611
+ * Idempotent for a retry after a dropped reply: when the device's row is already present
3612
+ * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
3613
+ * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
3614
+ * owner-granting invitation is completed: the usage row then names the existing peer stamp.
3615
+ * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
3616
+ * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
3617
+ *
3618
+ * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
3619
+ * every read passes `retry: false` and every stamp is minted per attempt, so a
3620
+ * {@link lockedWithRetry} re-run starts clean.
3621
+ *
3622
+ * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
3623
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
3624
+ */
3625
+ async redeemCadreInvite(params) {
3626
+ this.ensureInitialized();
3627
+ const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
3628
+ const nowMs = params.nowMs ?? Date.now();
3629
+ const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
3630
+ log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
3631
+ return this.mutateCadrePeer('cadre-invite-redeem', async () => {
3632
+ const invite = await this.queryCadreInvite(inviteKey, false);
3633
+ if (invite === null) {
3634
+ throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
3635
+ }
3636
+ const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
3637
+ if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
3638
+ log('redeemCadreInvite: %s already admitted; nothing written', peerId);
3639
+ return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
3640
+ }
3641
+ await this.assertCadreInviteSeatRemains(invite);
3642
+ // Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
3643
+ // the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
3644
+ // attempt rather than re-presenting a stamp the usage row may already name.
3645
+ const peerStampId = seated.peerStampId ?? generateStampId(peerId);
3646
+ const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
3647
+ const nowCanonical = await canonicalDatetime(this.db, nowMs);
3648
+ await this.inTransaction('cadre-invite-redeem', async () => {
3649
+ if (seated.peerStampId === null) {
3650
+ await this.db.exec(`
3651
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
3652
+ with context OwnerKey = null, Signature = null
3653
+ values (?, ?, ?, ?, null, ?, ?, null, ?)
3654
+ `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
3655
+ }
3656
+ if (ownerStampId !== null && seated.ownerStampId === null) {
3657
+ await this.db.exec(`
3658
+ insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
3659
+ with context OwnerKey = null, Signature = null
3660
+ values (?, ?, ?, null, ?)
3661
+ `, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
3662
+ }
3663
+ await this.db.exec(`
3664
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
3665
+ with context Now = ?
3666
+ values (?, ?, ?, ?, ?, ?, ?, ?)
3667
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
3668
+ });
3669
+ log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
3670
+ return { alreadyMember: false, peerStampId, ownerStampId };
3671
+ });
3672
+ }
3673
+ /**
3674
+ * The rows a redemption would seat that are already present: the device's `CadrePeer`
3675
+ * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
3676
+ * reads ({@link queryStampId}), so a physically present row counts; inside the locked
3677
+ * body, so never retried.
3678
+ */
3679
+ async readSeatedAdmission(peerId, peerKey, grantsOwner) {
3680
+ return {
3681
+ peerStampId: await this.queryStampId('CadrePeer', peerId, false),
3682
+ ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
3683
+ };
3684
+ }
3685
+ /**
3686
+ * Refuse a redemption that would consume a seat the cadre invitation does not have, by
3687
+ * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
3688
+ * {@link assertSeatRemains}, for the same reason: the schema's cap clause refuses as a
3689
+ * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
3690
+ * committed count inside the locked body, so on a same-node race the loser is refused here.
3691
+ */
3692
+ async assertCadreInviteSeatRemains(invite) {
3693
+ if (invite.totalUses === null) {
3694
+ return;
3695
+ }
3696
+ const used = await this.countCadreInviteUsage(invite.key, false);
3697
+ if (used >= invite.totalUses) {
3698
+ throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
3699
+ }
3700
+ }
2863
3701
  /**
2864
3702
  * Close the database and cleanup resources.
2865
3703
  *