@serfab/cadre-core 1.16.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +11 -2
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +652 -1052
  151. package/src/cadre-join-request.ts +24 -3
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +886 -789
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -4,11 +4,13 @@ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlu
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
- import { CONTROL_SCHEMA } from './control-schema.js';
7
+ import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, controlSchemaFileDeclaration } from './control-schema.js';
8
+ import { refuseIncompatibleControlStore } from './control-schema-version.js';
8
9
  import { canonicalDatetime } from './canonical-datetime.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';
10
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
11
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
12
+ import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
13
+ import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
12
14
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
13
15
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
14
16
  import { chainMessages } from './control-retry.js';
@@ -78,13 +80,14 @@ export class FormationAbortedError extends Error {
78
80
  /**
79
81
  * The invitation's seat budget is already spent: the count of recorded usage rows for the
80
82
  * 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.
83
+ * is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
84
+ * table the caller redeemed against.
83
85
  *
84
86
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
85
87
  * the write, inside {@link ControlDatabase.redeemInvitation} /
86
88
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
87
- * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
89
+ * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
90
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
88
91
  * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
89
92
  * pass — and off nothing else. It exists because
90
93
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
@@ -119,8 +122,42 @@ export class CadreInviteIssuerUnknownError extends Error {
119
122
  this.name = 'CadreInviteIssuerUnknownError';
120
123
  }
121
124
  }
122
- /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
123
- const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
125
+ /**
126
+ * {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
127
+ * grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
128
+ * `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
129
+ * Its own class rather than a shared one so a caller that handles invitation and grant
130
+ * bundles can tell by `instanceof` which credential was refused.
131
+ */
132
+ export class OwnerGrantIssuerUnknownError extends Error {
133
+ constructor(grantKey, issuerKey, options) {
134
+ super(`Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
135
+ this.grantKey = grantKey;
136
+ this.issuerKey = issuerKey;
137
+ this.name = 'OwnerGrantIssuerUnknownError';
138
+ }
139
+ }
140
+ /**
141
+ * An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
142
+ * yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
143
+ * insert refused by a cohort member already holding the row, and a refreshed read here still
144
+ * finds nothing; or the redemption that follows the seat finds no row. It clears once this
145
+ * node's store catches up, which is why the redemption paths answer it with the retryable
146
+ * `conflict` code.
147
+ */
148
+ export class IssuedRowNotHereYetError extends Error {
149
+ constructor(table, key, options) {
150
+ super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
151
+ this.table = table;
152
+ this.key = key;
153
+ this.name = 'IssuedRowNotHereYetError';
154
+ }
155
+ }
156
+ /**
157
+ * The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
158
+ * wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
159
+ */
160
+ const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
124
161
  /**
125
162
  * Parse a stored Quereus `datetime` value into epoch milliseconds.
126
163
  *
@@ -288,6 +325,39 @@ export function cadreInviteConsentMessage(partyId, fields) {
288
325
  fields.inviteKey, fields.usageStampId, fields.peerKey,
289
326
  ]);
290
327
  }
328
+ /**
329
+ * The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
330
+ * digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
331
+ * nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
332
+ * `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
333
+ * canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
334
+ * the reason on {@link cadreInviteAddMessage}.
335
+ */
336
+ export function ownerGrantAddMessage(partyId, row) {
337
+ return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
338
+ }
339
+ /**
340
+ * The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
341
+ * it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
342
+ * against the grant's public key. Binding the seated key and the nonce means a captured
343
+ * redemption can seat no other key and spend no other use.
344
+ */
345
+ export function ownerGrantRedeemMessage(partyId, fields) {
346
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
347
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
348
+ ]);
349
+ }
350
+ /**
351
+ * The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
352
+ * `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
353
+ * fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
354
+ * signature can stand in for the other.
355
+ */
356
+ export function ownerGrantConsentMessage(partyId, fields) {
357
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
358
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
359
+ ]);
360
+ }
291
361
  /** Runtime guard for the dynamic-`from` count, over the one table list. */
292
362
  const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
293
363
  /**
@@ -306,11 +376,14 @@ const GUARDED_KEY_COLUMN = {
306
376
  JoinRequest: 'Id',
307
377
  DeviceToken: 'PeerId',
308
378
  CadreInvite: 'Key',
379
+ OwnerGrant: 'Key',
309
380
  };
381
+ /** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
382
+ const ISSUED_TABLE_REASON = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
310
383
  /**
311
384
  * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
312
385
  * 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
386
+ * tombstone writer (a guarded removal, an issued row's withdrawal, the ledger marker) so the
314
387
  * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
315
388
  * `revocationDigest`.
316
389
  */
@@ -321,28 +394,46 @@ function revocationTombstoneMessage(partyId, ref) {
321
394
  * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
322
395
  * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
323
396
  */
324
- function ownerKeyAddMessage(partyId, key, stampId) {
325
- return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
397
+ function ownerKeyAddMessage(partyId, key, label, stampId) {
398
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
326
399
  }
327
- function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
400
+ /**
401
+ * An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
402
+ * {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
403
+ * Idempotent, so a caller that checks before opening an owner approval can pass the result on.
404
+ */
405
+ export function ownerKeyLabel(label) {
406
+ const trimmed = label?.trim();
407
+ if (!trimmed) {
408
+ return null;
409
+ }
410
+ if (trimmed.length > OWNER_KEY_MAX_LABEL) {
411
+ throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
412
+ }
413
+ return trimmed;
414
+ }
415
+ async function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
328
416
  const ref = { tableName, rowKey, stampId };
329
417
  return {
330
418
  ref,
331
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
332
- revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
419
+ signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
420
+ revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
333
421
  };
334
422
  }
335
423
  /**
336
- * The rejection a second insert of an already-seated id into `table` produces, as the
424
+ * The rejection a second insert of an already-seated key into `table` produces, as the
337
425
  * optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
338
- * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
426
+ * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
427
+ * naming the table's {@link GUARDED_KEY_COLUMN}.
339
428
  *
340
429
  * Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
341
- * only the primary key can collide on a repeat insert. Matching the column explicitly
342
- * keeps any OTHER uniqueness failure out of the caller's idempotency branch.
430
+ * only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
431
+ * stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
432
+ * Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
433
+ * idempotency branch.
343
434
  */
344
- function idConflictPattern(table) {
345
- return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
435
+ function keyConflictPattern(table) {
436
+ return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
346
437
  }
347
438
  /**
348
439
  * Did this write to `table` fail because the strand id is already seated there?
@@ -360,7 +451,16 @@ function idConflictPattern(table) {
360
451
  * reword reddens there.
361
452
  */
362
453
  export function isStrandIdConflict(error, table) {
363
- return errorChainMatches(error, idConflictPattern(table));
454
+ return errorChainMatches(error, keyConflictPattern(table));
455
+ }
456
+ /**
457
+ * Did a seat of an issued row fail because a cohort member already holds a row under its key?
458
+ * {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
459
+ * on a node whose store lags the cohort: its guard read missed the row, and the member that
460
+ * checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
461
+ */
462
+ export function isIssuedRowKeyConflict(error, table) {
463
+ return errorChainMatches(error, keyConflictPattern(table));
364
464
  }
365
465
  /**
366
466
  * Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
@@ -372,7 +472,7 @@ export function isStrandIdConflict(error, table) {
372
472
  * until the next reap pass.
373
473
  */
374
474
  export function isPendingJoinConflict(error) {
375
- return errorChainMatches(error, idConflictPattern('JoinRequest'));
475
+ return errorChainMatches(error, keyConflictPattern('JoinRequest'));
376
476
  }
377
477
  /**
378
478
  * The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
@@ -408,7 +508,7 @@ export const REVOCATION_LEDGER_MARKER = {
408
508
  };
409
509
  /**
410
510
  * The rejection a second insert of the ledger marker produces: the optimystic vtab's
411
- * primary-key wording (see {@link idConflictPattern}), naming both columns of
511
+ * primary-key wording (see {@link keyConflictPattern}), naming both columns of
412
512
  * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
413
513
  * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
414
514
  * has no other unique constraint.
@@ -424,41 +524,32 @@ function errorChainMatches(error, pattern) {
424
524
  }
425
525
  /**
426
526
  * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
427
- * redemption reply, carries.
527
+ * redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
528
+ * redemption; it contributes an entry only through its own `CadrePeer` row.
428
529
  *
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.
530
+ * A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
531
+ * receiver at that moment, since it is delivering the seed or the reply, or an always-on
532
+ * machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
533
+ * reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
534
+ * own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
535
+ * machine misdeclaring one costs the receiver a wasted dial at most.
441
536
  */
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
- });
537
+ export function projectSeedPeers(rows, minterPeerId) {
538
+ return rows.map(({ peerId, multiaddr, profile }) => ({
539
+ peerId,
540
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
541
+ preferred: peerId === minterPeerId || profile === 'storage',
542
+ }));
453
543
  }
454
544
  /**
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`.
545
+ * The conditions an issued credential (a cadre invitation or an owner grant) must still meet
546
+ * to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
547
+ * expired, as the redemption refuses it), and issued by a current owner. Shared by
548
+ * {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
549
+ * {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
550
+ * drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
460
551
  */
461
- function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
552
+ function issuedRowStillOpen(row, withdrawn, owners, nowMs) {
462
553
  if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
463
554
  return false;
464
555
  }
@@ -476,31 +567,30 @@ function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
476
567
  * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
477
568
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
478
569
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
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.
570
+ * party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
571
+ * tombstones are withdrawals over rows that stay), so there is nothing to reap.
481
572
  */
482
573
  export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
483
574
  /**
484
575
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
485
576
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
486
- * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
577
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
487
578
  * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
488
579
  * log line per skip would be per-pass noise.
489
580
  */
490
581
  const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
491
582
  const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
492
583
  /** 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';
584
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
494
585
  /**
495
586
  * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
496
587
  * 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.
588
+ * parsed to ms here.
498
589
  */
499
590
  function cadreInviteRowOf(row) {
500
591
  return {
501
592
  key: row.Key,
502
593
  peerId: row.PeerId ?? null,
503
- grantsOwner: Number(row.GrantsOwner) === 1,
504
594
  expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
505
595
  totalUses: row.TotalUses ?? null,
506
596
  issuerKey: row.IssuerKey,
@@ -516,11 +606,35 @@ function cadreInviteUsageRowOf(row) {
516
606
  peerId: row.PeerId,
517
607
  peerKey: row.PeerKey,
518
608
  peerStampId: row.PeerStampId,
519
- ownerStampId: row.OwnerStampId ?? null,
520
609
  inviteSig: row.InviteSig,
521
610
  peerSig: row.PeerSig,
522
611
  };
523
612
  }
613
+ /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; callers append their `where`. */
614
+ const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
615
+ /** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
616
+ function ownerGrantRowOf(row) {
617
+ return {
618
+ key: row.Key,
619
+ label: row.Label ?? null,
620
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
621
+ totalUses: row.TotalUses ?? null,
622
+ issuerKey: row.IssuerKey,
623
+ issuerSig: row.IssuerSig,
624
+ stampId: row.StampId,
625
+ };
626
+ }
627
+ /** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
628
+ function ownerGrantUsageRowOf(row) {
629
+ return {
630
+ usageStampId: row.UsageStampId,
631
+ grantKey: row.GrantKey,
632
+ seatedKey: row.SeatedKey,
633
+ seatedStampId: row.SeatedStampId,
634
+ grantSig: row.GrantSig,
635
+ ownerSig: row.OwnerSig,
636
+ };
637
+ }
524
638
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
525
639
  function joinedStrandRow(row) {
526
640
  return {
@@ -528,8 +642,20 @@ function joinedStrandRow(row) {
528
642
  MemberPrivateKey: row.MemberPrivateKey,
529
643
  Type: row.Type,
530
644
  FounderOwnerKey: null,
645
+ FounderPeerId: null,
646
+ };
647
+ }
648
+ /** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
649
+ function strandRow(row) {
650
+ return {
651
+ Id: row.Id,
652
+ MemberPrivateKey: row.MemberPrivateKey,
653
+ Type: row.Type,
654
+ FounderOwnerKey: row.FounderOwnerKey,
655
+ FounderPeerId: row.FounderPeerId,
531
656
  };
532
657
  }
658
+ const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
533
659
  const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
534
660
  function formationInviteRowOf(row) {
535
661
  return {
@@ -599,7 +725,7 @@ function joinRequestAddFields(row) {
599
725
  * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
600
726
  * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
601
727
  */
602
- function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
728
+ async function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
603
729
  const at = String(outcome.RecordedAt);
604
730
  const { table, sql, fields, values } = outcome.kind === 'joined'
605
731
  ? {
@@ -612,9 +738,28 @@ function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signM
612
738
  fields: [requestStampId, at, outcome.Code, outcome.Reason],
613
739
  values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
614
740
  };
615
- const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
741
+ const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
616
742
  return { sql, params: [ownerKey, signature, ...values] };
617
743
  }
744
+ /** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
745
+ async function readSchemaFile(schemaPath) {
746
+ if (typeof process === 'undefined' || !process.versions?.node) {
747
+ throw new Error('Loading schema from file is not supported in React Native. ' +
748
+ 'Remove the schemaPath option to use the embedded schema instead.');
749
+ }
750
+ try {
751
+ // Use require to conditionally load fs only in Node.js.
752
+ // This won't be bundled by React Native's Metro bundler — a dynamic
753
+ // import() would be statically picked up by Metro, so require is
754
+ // intentional here (cross-platform constraint, not lazy CommonJS).
755
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
756
+ const fs = require('fs/promises');
757
+ return await fs.readFile(schemaPath, 'utf-8');
758
+ }
759
+ catch (error) {
760
+ throw new Error(`Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
761
+ }
762
+ }
618
763
  /**
619
764
  * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
620
765
  * It provides typed query methods for accessing control network data.
@@ -741,44 +886,35 @@ export class ControlDatabase {
741
886
  log('ControlDatabase initialized successfully');
742
887
  }
743
888
  async loadSchema() {
744
- let schemaContent;
889
+ let declaration;
745
890
  if (this.config.schemaPath) {
746
- // Load from file if explicitly provided (for testing or custom schemas)
747
- // This only works in Node.js environments
748
891
  log('Loading schema from file: %s', this.config.schemaPath);
749
- // Check if we're in a Node.js environment
750
- if (typeof process !== 'undefined' && process.versions?.node) {
751
- try {
752
- // Use require to conditionally load fs only in Node.js.
753
- // This won't be bundled by React Native's Metro bundler — a dynamic
754
- // import() would be statically picked up by Metro, so require is
755
- // intentional here (cross-platform constraint, not lazy CommonJS).
756
- // eslint-disable-next-line @typescript-eslint/no-require-imports
757
- const fs = require('fs/promises');
758
- schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
759
- }
760
- catch (error) {
761
- throw new Error(`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
762
- }
763
- }
764
- else {
765
- throw new Error('Loading schema from file is not supported in React Native. ' +
766
- 'Remove the schemaPath option to use the embedded schema instead.');
767
- }
892
+ declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
768
893
  }
769
894
  else {
770
- // Use embedded schema for cross-platform compatibility
771
895
  log('Using embedded control schema');
772
- schemaContent = CONTROL_SCHEMA;
896
+ declaration = CONTROL_SCHEMA_DECLARATION;
773
897
  }
898
+ // Declaring runs no DDL; it only records the declaration the check and the apply both read.
899
+ await this.db.exec(declaration);
900
+ // A warm store this build cannot apply to without losing data is refused HERE, before the
901
+ // apply runs anything: the optimystic module refuses most changes to an existing table
902
+ // part-way through an apply, and a dropped table takes its rows. Migrating a store in place
903
+ // (control-store-migrates-on-start) widens this check to let catalog-only changes through.
904
+ await refuseIncompatibleControlStore(this.db, this.config.partyId);
905
+ await this.applyControlSchema();
906
+ log('Schema loaded and executed');
907
+ }
908
+ /** Apply the declared control schema, retrying transient cohort failures. */
909
+ async applyControlSchema() {
774
910
  // Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
775
911
  // retry every other control write does ({@link lockedWithRetry}). Every CadreControl
776
- // table is optimystic-backed (the default vtab was routed to optimystic just above),
912
+ // table is optimystic-backed (`initialize` routes the default vtab to optimystic),
777
913
  // so each `create table` needs a super-majority of the party's peers to answer. On a
778
914
  // node joining a party that already has two live members a single unanswered peer
779
915
  // during any one create otherwise kills startup outright.
780
916
  //
781
- // Re-running the WHOLE `exec` after a partial failure is safe, and this is the
917
+ // Re-running the apply after a partial failure is safe, and this is the
782
918
  // non-obvious part:
783
919
  //
784
920
  // - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
@@ -786,11 +922,6 @@ export class ControlDatabase {
786
922
  // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
787
923
  // A table the live catalog already lists generates no statements at all, which is what
788
924
  // makes an apply over an already-complete schema (a warm start) a no-op.
789
- // NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
790
- // `alter table ... drop constraint`, which the optimystic module refuses, so a store
791
- // written by a build with older constraint text fails here on every start (policy:
792
- // recreate, docs/architecture.md after the Control Network table). If backwards
793
- // compatibility becomes a requirement, this apply is the migration site.
794
925
  // - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
795
926
  // undo journal; when a step fails it runs that journal in reverse, re-renders the
796
927
  // catalog, compares it against a fingerprint taken BEFORE the apply, and only then
@@ -823,8 +954,7 @@ export class ControlDatabase {
823
954
  // also what lets schema init absorb optimystic's self-coordination grace refusal, which
824
955
  // is unsafe for control writes in general. Why, on
825
956
  // `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
826
- await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
827
- log('Schema loaded and executed');
957
+ await this.lockedWithRetry(() => this.db.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
828
958
  }
829
959
  /**
830
960
  * One UNRETRIED drain of a control read to rows — every read in this class bottoms out
@@ -994,16 +1124,8 @@ export class ControlDatabase {
994
1124
  */
995
1125
  async queryStrands() {
996
1126
  this.ensureInitialized();
997
- const results = [];
998
- for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
999
- results.push({
1000
- Id: row.Id,
1001
- MemberPrivateKey: row.MemberPrivateKey,
1002
- Type: row.Type,
1003
- FounderOwnerKey: row.FounderOwnerKey,
1004
- });
1005
- }
1006
- return results;
1127
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
1128
+ return rows.map(strandRow);
1007
1129
  }
1008
1130
  /**
1009
1131
  * Read a single strand row by id, or null when absent. Single-row sibling of
@@ -1013,20 +1135,13 @@ export class ControlDatabase {
1013
1135
  */
1014
1136
  async queryStrand(strandId) {
1015
1137
  this.ensureInitialized();
1016
- for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?', [strandId], 'strand')) {
1017
- return {
1018
- Id: row.Id,
1019
- MemberPrivateKey: row.MemberPrivateKey,
1020
- Type: row.Type,
1021
- FounderOwnerKey: row.FounderOwnerKey,
1022
- };
1023
- }
1024
- return null;
1138
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
1139
+ return rows.length === 0 ? null : strandRow(rows[0]);
1025
1140
  }
1026
1141
  /**
1027
1142
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
1028
1143
  * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
1029
- * never founds, so `FounderOwnerKey` is always null. Read raw, like {@link queryStrands}:
1144
+ * never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
1030
1145
  * no retired-stamp filter.
1031
1146
  */
1032
1147
  async queryJoinedStrands() {
@@ -1113,15 +1228,17 @@ export class ControlDatabase {
1113
1228
  * Idempotent genesis: insert `key` as the founding owner key only when
1114
1229
  * the party has none yet. Returns true if it inserted, false if an owner
1115
1230
  * key already existed (so a repeat `--owner` start is a no-op).
1231
+ *
1232
+ * @param label - the user's name for the key ({@link insertOwnerKey}).
1116
1233
  */
1117
- async ensureOwnerKey(key) {
1234
+ async ensureOwnerKey(key, label) {
1118
1235
  this.ensureInitialized();
1119
1236
  const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
1120
1237
  if (await this.hasOwnerKey()) {
1121
1238
  log('Owner key already present; skipping genesis insert');
1122
1239
  return false;
1123
1240
  }
1124
- await this.insertOwnerKey(trimmed);
1241
+ await this.insertOwnerKey(trimmed, label);
1125
1242
  return true;
1126
1243
  }
1127
1244
  /**
@@ -1150,8 +1267,9 @@ export class ControlDatabase {
1150
1267
  return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
1151
1268
  }
1152
1269
  /**
1153
- * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1154
- * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1270
+ * Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
1271
+ * the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
1272
+ * listing shows (`CadreNode.listOwnerKeys`). Rows whose
1155
1273
  * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1156
1274
  * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1157
1275
  * reads through this. A row with no stamp (the schema permits it; no writer produces one)
@@ -1171,7 +1289,7 @@ export class ControlDatabase {
1171
1289
  this.ensureInitialized();
1172
1290
  const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1173
1291
  const rows = [];
1174
- const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1292
+ const sql = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1175
1293
  for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1176
1294
  const stampId = row.StampId ?? null;
1177
1295
  if (stampId === null) {
@@ -1183,6 +1301,7 @@ export class ControlDatabase {
1183
1301
  }
1184
1302
  rows.push({
1185
1303
  key: row.Key,
1304
+ label: row.Label ?? null,
1186
1305
  stampId,
1187
1306
  vouchOwner: row.VouchOwner ?? null,
1188
1307
  vouchSig: row.VouchSig ?? null,
@@ -1257,16 +1376,16 @@ export class ControlDatabase {
1257
1376
  }
1258
1377
  /**
1259
1378
  * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
1260
- * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
1261
- * the replicated `OwnerKey` table ({@link projectSeedPeers}).
1379
+ * row with its addresses, the minter and every always-on machine marked preferred
1380
+ * ({@link projectSeedPeers}).
1262
1381
  *
1263
1382
  * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1264
1383
  * member's addresses are never packed into a seed or handed to a redeeming device as a
1265
1384
  * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1266
1385
  * its exits.
1267
1386
  */
1268
- async querySeedPeers() {
1269
- return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
1387
+ async querySeedPeers(minterPeerId) {
1388
+ return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
1270
1389
  }
1271
1390
  /**
1272
1391
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
@@ -1603,9 +1722,13 @@ export class ControlDatabase {
1603
1722
  }
1604
1723
  /**
1605
1724
  * Insert the initial owner key (bootstrap - no existing owners required)
1725
+ *
1726
+ * @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
1727
+ * characters once trimmed; blank is stored as none.
1606
1728
  */
1607
- async insertOwnerKey(key) {
1729
+ async insertOwnerKey(key, label) {
1608
1730
  this.ensureInitialized();
1731
+ const storedLabel = ownerKeyLabel(label);
1609
1732
  log('Inserting owner key: %s', key);
1610
1733
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
1611
1734
  // committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
@@ -1617,60 +1740,81 @@ export class ControlDatabase {
1617
1740
  // optimystic `StampId()` SQL function.
1618
1741
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1619
1742
  await this.execWrite(`
1620
- insert into CadreControl.OwnerKey (Key, StampId)
1743
+ insert into CadreControl.OwnerKey (Key, Label, StampId)
1621
1744
  with context OwnerKey = null, Signature = null
1622
- values (?, ?)
1623
- `, [key, stampId], 'owner-key-insert');
1745
+ values (?, ?, ?)
1746
+ `, [key, storedLabel, stampId], 'owner-key-insert');
1624
1747
  log('Owner key inserted');
1625
1748
  }
1626
1749
  /**
1627
1750
  * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
1628
1751
  * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
1629
1752
  * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
1630
- * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
1631
- * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
1753
+ * (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
1754
+ * reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
1632
1755
  *
1633
1756
  * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
1634
1757
  * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
1635
1758
  * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
1636
1759
  *
1637
- * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
1638
- * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
1639
- * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
1640
- * would collide on the primary key. Re-add from the machine that removed the key, or any
1641
- * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
1642
- * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
1643
- *
1760
+ * @param label - the user's name for the key, as {@link insertOwnerKey}'s.
1644
1761
  * @returns `true` when this call seated the row, `false` when `key` is already a live owner
1645
- * here (nothing written).
1646
- * @throws when this node holds `key`'s row under a retired stamp (above), or when the
1647
- * schema refuses the signer.
1762
+ * here (nothing written, whatever its label).
1763
+ * @throws when this node holds `key`'s row under a retired stamp
1764
+ * ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
1648
1765
  */
1649
- async insertOwnerKeyVouched(key, ownerKey, signMessage) {
1766
+ async insertOwnerKeyVouched(key, ownerKey, signMessage, label) {
1650
1767
  this.ensureInitialized();
1768
+ const storedLabel = ownerKeyLabel(label);
1651
1769
  return await this.mutateCadrePeer('owner-key-add', async () => {
1652
- // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1653
- const heldStamp = await this.queryStampId('OwnerKey', key, false);
1654
- if (heldStamp !== null) {
1655
- if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
1656
- log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
1657
- return false;
1658
- }
1659
- throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
1660
- '(OwnerKey rows are not reaped); re-add it from the machine that removed it');
1770
+ if (await this.liveOwnerKeyStamp(key) !== null) {
1771
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
1772
+ return false;
1661
1773
  }
1662
1774
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1663
- const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
1664
- // Bare `exec`: already inside the write lock, which is NOT re-entrant.
1665
- await this.db.exec(`
1666
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1667
- with context OwnerKey = ?, Signature = ?
1668
- values (?, ?, ?, ?)
1669
- `, [ownerKey, signature, key, stampId, ownerKey, signature]);
1775
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
1776
+ await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
1670
1777
  log('Owner key %s added (vouched by %s)', key, ownerKey);
1671
1778
  return true;
1672
1779
  });
1673
1780
  }
1781
+ /**
1782
+ * The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
1783
+ * insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
1784
+ * the locked body (both reads `retry: false`; see queryStampId's NOTE).
1785
+ *
1786
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
1787
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
1788
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
1789
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
1790
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
1791
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
1792
+ *
1793
+ * @throws when this node holds `key`'s row under a retired stamp (above).
1794
+ */
1795
+ async liveOwnerKeyStamp(key) {
1796
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
1797
+ if (heldStamp === null) {
1798
+ return null;
1799
+ }
1800
+ if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
1801
+ throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
1802
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it');
1803
+ }
1804
+ return heldStamp;
1805
+ }
1806
+ /**
1807
+ * The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
1808
+ * stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
1809
+ * NOT re-entrant.
1810
+ */
1811
+ async execOwnerKeyVouchedInsert(row, ownerKey, signature) {
1812
+ await this.db.exec(`
1813
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
1814
+ with context OwnerKey = ?, Signature = ?
1815
+ values (?, ?, ?, ?, ?)
1816
+ `, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
1817
+ }
1674
1818
  /**
1675
1819
  * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
1676
1820
  * stamp, notifying the membership listener once it has committed. The remove branch of
@@ -1708,14 +1852,14 @@ export class ControlDatabase {
1708
1852
  /**
1709
1853
  * Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
1710
1854
  * current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
1711
- * of the same key under a fresh stamp, signed by `ownerKey` ({@link insertOwnerKeyVouched}'s
1712
- * body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
1855
+ * of the same key and label under a fresh stamp, signed by `ownerKey`
1856
+ * ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
1713
1857
  * this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
1714
1858
  * under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
1715
- * {@link removeMachine}: the owners a machine being removed had added keep their anchor
1716
- * place everywhere once their row stands on a remaining owner's signature.
1859
+ * `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
1860
+ * everywhere once their row stands on a remaining owner's signature.
1717
1861
  *
1718
- * Through {@link mutateCadrePeer}, the membership hub. The stamp read runs inside the locked
1862
+ * Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
1719
1863
  * body; a row whose stamp is already retired (held without its delete) is reported as absent
1720
1864
  * rather than tombstoned twice.
1721
1865
  *
@@ -1727,59 +1871,48 @@ export class ControlDatabase {
1727
1871
  throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
1728
1872
  }
1729
1873
  return await this.mutateCadrePeer('owner-key-revouch', async () => {
1730
- // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1731
- const stampId = await this.queryStampId('OwnerKey', key, false);
1732
- if (stampId === null || (await this.queryRevokedStamps('OwnerKey', false)).has(stampId)) {
1874
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
1875
+ const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
1876
+ if (live === undefined) {
1733
1877
  log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
1734
1878
  return false;
1735
1879
  }
1736
- const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
1737
- const freshStampId = generateStampId(this.config.libp2pNode.peerId.toString());
1738
- const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, freshStampId));
1880
+ const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
1881
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1882
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
1739
1883
  await this.inTransaction('revouch OwnerKey', async () => {
1740
1884
  await this.execGuardedRemoval(removal, ownerKey);
1741
- await this.db.exec(`
1742
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1743
- with context OwnerKey = ?, Signature = ?
1744
- values (?, ?, ?, ?)
1745
- `, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
1885
+ await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
1746
1886
  });
1747
- log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
1887
+ log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
1748
1888
  this.notifyGuardedDelete(removal.ref);
1749
1889
  return true;
1750
1890
  });
1751
1891
  }
1752
1892
  /**
1753
- * Remove one machine completely, in ONE transaction: its `CadrePeer` row, its `OwnerKey`
1754
- * row when `target.ownerKey` names one, its `DeviceToken` row when it has one, each with
1755
- * the `Revocation` tombstone retiring its stamp ({@link deleteGuardedRow}'s statement pair,
1756
- * composed bare as {@link deleteStrandAndPartyKey} composes it), plus a withdrawal tombstone
1757
- * for every `CadreInvite` in `target.inviteKeys` that is still live here
1758
- * ({@link withdrawCadreInvite}'s body). One transaction so no reader sees the machine half
1759
- * removed: a row gone but its push token resolvable, or its owner key gone but an invitation
1760
- * it could redeem again still open.
1893
+ * Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
1894
+ * `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
1895
+ * ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
1896
+ * composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
1897
+ * that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
1898
+ * sees the machine half removed: a row gone but its push token resolvable, or an invitation
1899
+ * it could redeem again still open. Owner keys are never touched: they are the person's, and
1900
+ * removing one is {@link deleteOwnerKey}.
1761
1901
  *
1762
1902
  * Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
1763
1903
  * body, and a row whose stamp is already retired (held without its delete) is skipped rather
1764
- * than tombstoned twice. The by-name owner refusals of {@link deleteOwnerKey} apply to the
1765
- * owner-key arm. Nothing to remove writes nothing and returns all false.
1766
- *
1767
- * The caller decides WHAT to remove, which owner key is the machine's and which invitations
1768
- * it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
1769
- * write an inconsistent set.
1904
+ * than tombstoned twice. Nothing to remove writes nothing and returns all false.
1770
1905
  *
1771
- * @throws when `target.ownerKey` is `ownerKey`, or is the only live owner here.
1906
+ * The caller decides which invitations the machine could use, from its plan
1907
+ * (`CadreNode.planMachineRemoval`).
1772
1908
  */
1773
1909
  async removeMachine(target, ownerKey, signMessage) {
1774
- if (target.ownerKey === ownerKey) {
1775
- throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
1776
- }
1777
1910
  return await this.mutateCadrePeer('machine-remove', async () => {
1778
- const removals = await this.signMachineRemovals(target, signMessage);
1779
- const withdrawals = await this.signCadreInviteWithdrawals(target.inviteKeys, signMessage);
1911
+ const removals = await this.signMachineRemovals(target.peerId, signMessage);
1912
+ const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
1780
1913
  if (removals.length === 0 && withdrawals.length === 0) {
1781
1914
  log('removeMachine: nothing live names %s here (already removed)', target.peerId);
1782
- return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [] };
1915
+ return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
1783
1916
  }
1784
1917
  await this.inTransaction('remove machine', async () => {
1785
1918
  for (const removal of removals) {
@@ -1799,7 +1932,6 @@ export class ControlDatabase {
1799
1932
  }
1800
1933
  return {
1801
1934
  removedPeer: removed.has('CadrePeer'),
1802
- removedOwnerKey: removed.has('OwnerKey'),
1803
1935
  removedDeviceToken: removed.has('DeviceToken'),
1804
1936
  withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
1805
1937
  };
@@ -1807,11 +1939,11 @@ export class ControlDatabase {
1807
1939
  }
1808
1940
  /**
1809
1941
  * The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
1810
- * the `CadrePeer` row, the `OwnerKey` row when named, the `DeviceToken` row when present.
1811
- * Inside the locked body (every read `retry: false`); one `Revocation` scan serves the
1812
- * retired-stamp check for all three tables and the last-owner refusal.
1942
+ * the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
1943
+ * (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
1944
+ * tables.
1813
1945
  */
1814
- async signMachineRemovals(target, signMessage) {
1946
+ async signMachineRemovals(peerId, signMessage) {
1815
1947
  const retiredByTable = new Map();
1816
1948
  for (const tombstone of await this.queryRevocations(false)) {
1817
1949
  const stamps = retiredByTable.get(tombstone.tableName) ?? new Set();
@@ -1824,24 +1956,12 @@ export class ControlDatabase {
1824
1956
  return stampId !== null && !retired(table).has(stampId) ? stampId : null;
1825
1957
  };
1826
1958
  const removals = [];
1827
- const peerStamp = await liveStamp('CadrePeer', target.peerId);
1828
- if (peerStamp !== null) {
1829
- removals.push(signGuardedRemoval(this.config.partyId, 'CadrePeer', target.peerId, peerStamp, signMessage));
1830
- }
1831
- if (target.ownerKey !== null) {
1832
- const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
1833
- if (ownerStamp !== null) {
1834
- const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
1835
- if (owners.length <= 1) {
1836
- throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
1837
- }
1838
- removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
1959
+ for (const table of ['CadrePeer', 'DeviceToken']) {
1960
+ const stampId = await liveStamp(table, peerId);
1961
+ if (stampId !== null) {
1962
+ removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
1839
1963
  }
1840
1964
  }
1841
- const tokenStamp = await liveStamp('DeviceToken', target.peerId);
1842
- if (tokenStamp !== null) {
1843
- removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
1844
- }
1845
1965
  return removals;
1846
1966
  }
1847
1967
  /**
@@ -1870,18 +1990,18 @@ export class ControlDatabase {
1870
1990
  const peerId = this.config.libp2pNode.peerId.toString();
1871
1991
  const stampId = generateStampId(peerId);
1872
1992
  // Field order MUST match the schema's Strand `AuthorizedInsert` verify:
1873
- // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
1993
+ // Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
1874
1994
  // deliberately NOT in the digest — the schema binds it by equality to the
1875
1995
  // verified context.OwnerKey instead (see the constraint's comment).
1876
- const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
1877
- const signature = signMessage(message);
1996
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
1997
+ const signature = await signMessage(message);
1878
1998
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
1879
1999
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
1880
2000
  await this.execWrite(`
1881
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
2001
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
1882
2002
  with context OwnerKey = ?, Signature = ?
1883
- values (?, ?, ?, ?, ?)
1884
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
2003
+ values (?, ?, ?, ?, ?, ?)
2004
+ `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId], 'strand-insert');
1885
2005
  log('Strand inserted: %s', strandId);
1886
2006
  }
1887
2007
  /**
@@ -1935,10 +2055,10 @@ export class ControlDatabase {
1935
2055
  return false;
1936
2056
  }
1937
2057
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1938
- const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
2058
+ const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
1939
2059
  const partyKeyRemoval = partyKeyStamp === null
1940
2060
  ? null
1941
- : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
2061
+ : await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
1942
2062
  await this.inTransaction('delete Strand', async () => {
1943
2063
  await this.execGuardedRemoval(strandRemoval, ownerKey);
1944
2064
  if (partyKeyRemoval !== null) {
@@ -1976,7 +2096,7 @@ export class ControlDatabase {
1976
2096
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
1977
2097
  // Id, PrivateKey, StampId.
1978
2098
  const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
1979
- const signature = signMessage(message);
2099
+ const signature = await signMessage(message);
1980
2100
  await this.execWrite(`
1981
2101
  insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
1982
2102
  with context OwnerKey = ?, Signature = ?
@@ -2018,7 +2138,7 @@ export class ControlDatabase {
2018
2138
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
2019
2139
  // Id, Type, MemberPrivateKey ('' when null), StampId.
2020
2140
  const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
2021
- const signature = signMessage(message);
2141
+ const signature = await signMessage(message);
2022
2142
  await this.execWrite(`
2023
2143
  insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
2024
2144
  with context OwnerKey = ?, Signature = ?
@@ -2044,7 +2164,7 @@ export class ControlDatabase {
2044
2164
  */
2045
2165
  async insertJoinRequest(request, ownerKey, signMessage) {
2046
2166
  this.ensureInitialized();
2047
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2167
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2048
2168
  await this.execWrite(insert.sql, insert.params, 'join-request-insert');
2049
2169
  log('Join request inserted: %s', request.Id);
2050
2170
  return { ...written, outcome: null };
@@ -2064,7 +2184,7 @@ export class ControlDatabase {
2064
2184
  */
2065
2185
  async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
2066
2186
  this.ensureInitialized();
2067
- const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2187
+ const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2068
2188
  return this.lockedWithRetry(async () => {
2069
2189
  const live = await this.readPendingJoin(expected.Id, false);
2070
2190
  if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
@@ -2097,9 +2217,9 @@ export class ControlDatabase {
2097
2217
  async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
2098
2218
  this.ensureInitialized();
2099
2219
  const { outcome, ...request } = next;
2100
- const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2101
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2102
- const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2220
+ const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2221
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2222
+ const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2103
2223
  return this.lockedWithRetry(async () => {
2104
2224
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
2105
2225
  const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
@@ -2132,9 +2252,9 @@ export class ControlDatabase {
2132
2252
  * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
2133
2253
  * verifies, returning the row as it will be written and its insert.
2134
2254
  */
2135
- signJoinRequestInsert(request, ownerKey, signMessage) {
2255
+ async signJoinRequestInsert(request, ownerKey, signMessage) {
2136
2256
  const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
2137
- const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2257
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2138
2258
  const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
2139
2259
  return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
2140
2260
  }
@@ -2175,7 +2295,7 @@ export class ControlDatabase {
2175
2295
  const stampId = generateStampId(peerId);
2176
2296
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
2177
2297
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
2178
- const signature = signMessage(message);
2298
+ const signature = await signMessage(message);
2179
2299
  await this.execWrite(`
2180
2300
  insert into CadreControl.ValidationKey (Key, StampId)
2181
2301
  with context OwnerKey = ?, Signature = ?
@@ -2244,7 +2364,7 @@ export class ControlDatabase {
2244
2364
  // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
2245
2365
  // row it vouches.
2246
2366
  const stampId = generateStampId(row.peerId);
2247
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
2367
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
2248
2368
  return await this.mutateCadrePeer('peer-insert', async () => {
2249
2369
  // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
2250
2370
  if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
@@ -2305,13 +2425,13 @@ export class ControlDatabase {
2305
2425
  *
2306
2426
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
2307
2427
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
2308
- * reader's node-local anchor. Benign today because the only caller — the write-while-alone
2309
- * drain — re-touches solely rows this node itself authored (`pendingPeerWrites`), so the
2310
- * voucher is rewritten to the key that already signed it. If a future path ever lets one
2311
- * owner re-touch a row a DIFFERENT owner vouched, the voucher silently flips: readers that
2312
- * anchor the original owner but not this one would drop a legitimate member. Such a path
2313
- * must re-vouch deliberately (or preserve the existing VouchOwner/VouchSig) rather than
2314
- * inherit this rebinding.
2428
+ * reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
2429
+ * re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
2430
+ * rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
2431
+ * key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
2432
+ * flips the voucher silently: readers that anchor the original owner but not this one would
2433
+ * drop a legitimate member. It must re-vouch deliberately (or preserve the existing
2434
+ * VouchOwner/VouchSig) rather than inherit this rebinding.
2315
2435
  *
2316
2436
  * @param peerId - the membership row to re-touch.
2317
2437
  * @param updatedAt - the strictly-increasing freshness stamp to write.
@@ -2326,7 +2446,7 @@ export class ControlDatabase {
2326
2446
  log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
2327
2447
  return false;
2328
2448
  }
2329
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
2449
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
2330
2450
  await this.mutateCadrePeer('peer-reauthorize', async () => {
2331
2451
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2332
2452
  await this.db.exec(`
@@ -2412,7 +2532,7 @@ export class ControlDatabase {
2412
2532
  log('delete %s: no row for %s (already absent)', table, keyValue);
2413
2533
  return false;
2414
2534
  }
2415
- const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2535
+ const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2416
2536
  await this.inTransaction(`delete ${table}`, async () => {
2417
2537
  await alongside?.(stampId);
2418
2538
  await this.execGuardedRemoval(removal, ownerKey);
@@ -2445,8 +2565,8 @@ export class ControlDatabase {
2445
2565
  }
2446
2566
  /**
2447
2567
  * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2448
- * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
2449
- * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
2568
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
2569
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
2450
2570
  * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2451
2571
  * lock and, where one is needed, the transaction.
2452
2572
  *
@@ -2692,17 +2812,20 @@ export class ControlDatabase {
2692
2812
  *
2693
2813
  * Returns how many UPDATE statements ran (`rows.length` on success).
2694
2814
  */
2695
- reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
2815
+ async reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
2696
2816
  this.ensureInitialized();
2697
2817
  // Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
2698
2818
  // exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
2699
2819
  // rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
2700
2820
  // and every digest field is TEXT on both sides (see buildAuthorizationMessage).
2701
- const signed = rows.map((row) => ({
2702
- row,
2703
- signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2704
- }));
2705
- return this.lockedWithRetry(async () => {
2821
+ const signed = [];
2822
+ for (const row of rows) {
2823
+ signed.push({
2824
+ row,
2825
+ signature: await signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2826
+ });
2827
+ }
2828
+ return await this.lockedWithRetry(async () => {
2706
2829
  // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
2707
2830
  // re-executes the whole body) starts from zero instead of double-counting.
2708
2831
  let executed = 0;
@@ -2766,7 +2889,7 @@ export class ControlDatabase {
2766
2889
  */
2767
2890
  async openRevocationLedger(ownerKey, signMessage) {
2768
2891
  this.ensureInitialized();
2769
- const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2892
+ const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2770
2893
  try {
2771
2894
  return await this.lockedWithRetry(async () => {
2772
2895
  if (await this.revocationLedgerFiled()) {
@@ -2876,8 +2999,9 @@ export class ControlDatabase {
2876
2999
  * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
2877
3000
  * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
2878
3001
  * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
2879
- * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
2880
- * which seats both. That is what makes the party-membership snapshot refresh automatic
3002
+ * {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
3003
+ * seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
3004
+ * makes the party-membership snapshot refresh automatic
2881
3005
  * rather than a caller obligation. The name predates the `OwnerKey` writers. A
2882
3006
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
2883
3007
  * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
@@ -2988,7 +3112,7 @@ export class ControlDatabase {
2988
3112
  * backoff never stalls the other local writers queued behind it. Safe because every
2989
3113
  * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
2990
3114
  *
2991
- * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
3115
+ * Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
2992
3116
  * `initialized` is set — so it reaches this method directly rather than through
2993
3117
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
2994
3118
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
@@ -3144,7 +3268,7 @@ export class ControlDatabase {
3144
3268
  const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
3145
3269
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
3146
3270
  ]);
3147
- const signature = signMessage(message);
3271
+ const signature = await signMessage(message);
3148
3272
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
3149
3273
  // signed source-of-truth and the stored value are produced once. StampId is a real,
3150
3274
  // unique column (single-use anti-replay), no longer a context value.
@@ -3217,13 +3341,13 @@ export class ControlDatabase {
3217
3341
  await this.inTransaction('redemption', async () => {
3218
3342
  // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
3219
3343
  // still carrying a fresh unique StampId for the anti-replay column.
3220
- // Hard-coded open + keyless + explicit null FounderOwnerKey: the
3344
+ // Hard-coded open + keyless + explicit null founder columns: the
3221
3345
  // consent branch admits no other shape (no signature means no
3222
- // trustworthy founder machine to record — see the schema's NOTE).
3346
+ // trustworthy founder machine to record — see the schema's comment).
3223
3347
  await this.db.exec(`
3224
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
3348
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
3225
3349
  with context OwnerKey = null, Signature = null
3226
- values (?, 'o', null, ?, null)
3350
+ values (?, 'o', null, ?, null, null)
3227
3351
  `, [strandId, strandStampId]);
3228
3352
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
3229
3353
  // carrying the strand's stamp so it authorizes THIS row and no other.
@@ -3492,73 +3616,116 @@ export class ControlDatabase {
3492
3616
  * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
3493
3617
  * same shape).
3494
3618
  *
3495
- * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
3496
- * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
3497
- * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
3498
- * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
3499
- * @param invite.totalUses - how many redemptions it allows; omit for unlimited
3500
3619
  * @returns the row as stored, `expiresAt` in its canonical form
3501
3620
  */
3502
3621
  async insertCadreInvite(invite, ownerKey, signMessage) {
3622
+ const row = await this.signCadreInvite(invite, ownerKey, signMessage);
3623
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
3624
+ log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
3625
+ return row;
3626
+ }
3627
+ /**
3628
+ * Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
3629
+ * {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
3630
+ * invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
3631
+ * issuer would refuse the local insert and the row is seated at a member from the bundle
3632
+ * instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
3633
+ * store, so the row reads the same wherever it is seated.
3634
+ */
3635
+ async signCadreInvite(invite, ownerKey, signMessage) {
3503
3636
  this.ensureInitialized();
3504
3637
  const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
3505
3638
  const signed = {
3506
3639
  key,
3507
3640
  peerId: invite.peerId ?? null,
3508
- grantsOwner: invite.grantsOwner,
3509
3641
  expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
3510
3642
  totalUses: invite.totalUses ?? null,
3511
3643
  stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
3512
3644
  };
3513
- const row = {
3645
+ return {
3514
3646
  ...signed,
3515
3647
  issuerKey: ownerKey,
3516
- issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
3648
+ issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
3517
3649
  };
3518
- await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
3519
- log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
3520
- return row;
3521
3650
  }
3522
3651
  /**
3523
3652
  * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
3524
3653
  * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
3525
- * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
3654
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
3655
+ * and so is one whose insert the cohort refuses because a member there holds it.
3526
3656
  *
3527
3657
  * @returns `true` when this call inserted the row, `false` when it was already present.
3528
3658
  * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
3529
3659
  * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
3530
- * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3660
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
3661
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3531
3662
  */
3532
3663
  async seatCadreInvite(row) {
3664
+ return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row), cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
3665
+ }
3666
+ /**
3667
+ * Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
3668
+ * {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
3669
+ * row's own stored issuer pair.
3670
+ *
3671
+ * @returns `true` when this call inserted the row, `false` when it was already present —
3672
+ * here, or at the cohort member that refused the insert for holding it
3673
+ * ({@link adoptCohortHeldRow}).
3674
+ * @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
3675
+ * {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
3676
+ * read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3677
+ */
3678
+ async seatIssuedRow(table, key, exec, refuse) {
3533
3679
  this.ensureInitialized();
3534
3680
  try {
3535
3681
  return await this.lockedWithRetry(async () => {
3536
3682
  // retry: false — inside the locked write body (see queryStampId's NOTE).
3537
- if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
3538
- log('CadreInvite %s already seated; seat skipped', row.key);
3683
+ if (await this.queryStampId(table, key, false) !== null) {
3684
+ log('%s %s already seated; seat skipped', table, key);
3539
3685
  return false;
3540
3686
  }
3541
- await this.execCadreInviteInsert(row);
3542
- log('Cadre invitation seated from its bundle: %s', row.key);
3687
+ await exec();
3688
+ log('%s seated from its bundle: %s', table, key);
3543
3689
  return true;
3544
- }, {}, 'cadre-invite-seat');
3690
+ }, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
3545
3691
  }
3546
3692
  catch (error) {
3547
- if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
3548
- throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
3693
+ if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
3694
+ throw refuse(error);
3695
+ }
3696
+ if (isIssuedRowKeyConflict(error, table)) {
3697
+ return await this.adoptCohortHeldRow(table, key, error);
3549
3698
  }
3550
3699
  throw error;
3551
3700
  }
3552
3701
  }
3702
+ /**
3703
+ * The seat's insert was refused because a cohort member already holds a row under `key`, which
3704
+ * this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
3705
+ * and every column is under the issuer's signature, so the held row is this one: it counts as
3706
+ * seated once a refreshing read (unlocked, so retried as any read) finds it here.
3707
+ *
3708
+ * @throws {IssuedRowNotHereYetError} when the read still does not find it.
3709
+ */
3710
+ async adoptCohortHeldRow(table, key, conflict) {
3711
+ // NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
3712
+ // which does not refresh from the cohort, so it can miss the row and refuse a retryable
3713
+ // `conflict`; if that refusal shows up in practice, wait for the running body before reading.
3714
+ if (await this.queryStampId(table, key) === null) {
3715
+ throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
3716
+ }
3717
+ log('%s %s is held by the cohort; seat skipped', table, key);
3718
+ return false;
3719
+ }
3553
3720
  /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
3554
3721
  async execCadreInviteInsert(row) {
3555
3722
  await this.db.exec(`
3556
- insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
3723
+ insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
3557
3724
  with context OwnerKey = ?, Signature = ?
3558
- values (?, ?, ?, ?, ?, ?, ?, ?)
3725
+ values (?, ?, ?, ?, ?, ?, ?)
3559
3726
  `, [
3560
3727
  row.issuerKey, row.issuerSig,
3561
- row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
3728
+ row.key, row.peerId, row.expiresAt, row.totalUses,
3562
3729
  row.issuerKey, row.issuerSig, row.stampId,
3563
3730
  ]);
3564
3731
  }
@@ -3596,7 +3763,7 @@ export class ControlDatabase {
3596
3763
  */
3597
3764
  async queryCadreInviteUsages(retry = true) {
3598
3765
  this.ensureInitialized();
3599
- const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
3766
+ const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
3600
3767
  return rows.map(cadreInviteUsageRowOf);
3601
3768
  }
3602
3769
  /**
@@ -3607,9 +3774,12 @@ export class ControlDatabase {
3607
3774
  * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
3608
3775
  */
3609
3776
  async countCadreInviteUsage(key, retry = true) {
3777
+ return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
3778
+ }
3779
+ /** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
3780
+ async countUsageRows(sql, key, label, retry) {
3610
3781
  this.ensureInitialized();
3611
- const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
3612
- for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
3782
+ for (const row of await this.readRows(sql, [key], label, retry)) {
3613
3783
  return row.Count ?? 0;
3614
3784
  }
3615
3785
  return 0;
@@ -3640,7 +3810,7 @@ export class ControlDatabase {
3640
3810
  const metered = [];
3641
3811
  let unlimitedLive = false;
3642
3812
  for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
3643
- const open = cadreInviteStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
3813
+ const open = issuedRowStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
3644
3814
  if (!open) {
3645
3815
  continue;
3646
3816
  }
@@ -3677,14 +3847,21 @@ export class ControlDatabase {
3677
3847
  async isCadreInviteLive(invite, nowMs = Date.now()) {
3678
3848
  this.ensureInitialized();
3679
3849
  const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
3850
+ return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
3851
+ }
3852
+ /**
3853
+ * The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
3854
+ * or a verified bundle copy; null for neither) still open and under its use cap?
3855
+ */
3856
+ async issuedRowLive(table, row, nowMs, countUsage) {
3680
3857
  if (row === null) {
3681
3858
  return false;
3682
3859
  }
3683
- const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
3684
- if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
3860
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
3861
+ if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
3685
3862
  return false;
3686
3863
  }
3687
- return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
3864
+ return row.totalUses === null || await countUsage(row.key) < row.totalUses;
3688
3865
  }
3689
3866
  /**
3690
3867
  * Every `CadreInvite` row this node holds with its standing: how many redemptions are
@@ -3694,22 +3871,28 @@ export class ControlDatabase {
3694
3871
  * occasional read, not a gate's.
3695
3872
  */
3696
3873
  async listCadreInviteStatuses(nowMs = Date.now()) {
3874
+ const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
3875
+ return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
3876
+ }
3877
+ /**
3878
+ * The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
3879
+ * `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
3880
+ */
3881
+ async issuedRowStandings(table, rows, nowMs, countUsage) {
3697
3882
  this.ensureInitialized();
3698
- const [invites, withdrawnStamps, owners] = await Promise.all([
3699
- this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
3700
- ]);
3701
- const statuses = [];
3702
- // NOTE: one retried count read per invitation, withdrawn and expired rows included, since
3703
- // rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
3704
- // usage counts in one statement or skip the count for rows that are no longer open.
3705
- for (const invite of invites) {
3706
- const usesRecorded = await this.countCadreInviteUsage(invite.key);
3707
- const withdrawn = withdrawnStamps.has(invite.stampId);
3708
- const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
3709
- && (invite.totalUses === null || usesRecorded < invite.totalUses);
3710
- statuses.push({ invite, live, withdrawn, usesRecorded });
3883
+ const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
3884
+ const standings = [];
3885
+ // NOTE: one retried count read per row, withdrawn and expired rows included, since rows
3886
+ // are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
3887
+ // counts in one statement or skip the count for rows that are no longer open.
3888
+ for (const row of rows) {
3889
+ const usesRecorded = await countUsage(row.key);
3890
+ const withdrawn = withdrawnStamps.has(row.stampId);
3891
+ const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
3892
+ && (row.totalUses === null || usesRecorded < row.totalUses);
3893
+ standings.push({ row, live, withdrawn, usesRecorded });
3711
3894
  }
3712
- return statuses;
3895
+ return standings;
3713
3896
  }
3714
3897
  /**
3715
3898
  * Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
@@ -3729,59 +3912,66 @@ export class ControlDatabase {
3729
3912
  * node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
3730
3913
  */
3731
3914
  async withdrawCadreInvite(key, ownerKey, signMessage) {
3915
+ return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
3916
+ }
3917
+ /**
3918
+ * The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
3919
+ * lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
3920
+ * the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
3921
+ */
3922
+ async withdrawIssuedRow(table, key, ownerKey, signMessage) {
3732
3923
  this.ensureInitialized();
3733
3924
  const filed = await this.lockedWithRetry(async () => {
3734
- const [withdrawal] = await this.signCadreInviteWithdrawals([key], signMessage);
3925
+ const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
3735
3926
  if (withdrawal === undefined) {
3736
3927
  return null;
3737
3928
  }
3738
3929
  await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
3739
3930
  return withdrawal.ref;
3740
- }, {}, 'cadre-invite-withdraw');
3931
+ }, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
3741
3932
  if (filed === null) {
3742
3933
  return false;
3743
3934
  }
3744
- log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
3935
+ log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
3745
3936
  this.notifyGuardedDelete(filed);
3746
3937
  return true;
3747
3938
  }
3748
3939
  /**
3749
- * The withdrawal tombstones for the `CadreInvite` rows among `keys` that are held here and
3750
- * not yet withdrawn, signed and ready for {@link execTombstone}: the body
3751
- * {@link withdrawCadreInvite} files one of and {@link removeMachine} files several of in one
3940
+ * The withdrawal tombstones for the `table` rows among `keys` that are held here and not
3941
+ * yet withdrawn, signed and ready for {@link execTombstone}: the body
3942
+ * {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
3752
3943
  * transaction. Inside the locked body (every read `retry: false`); an absent or already
3753
3944
  * withdrawn key is logged and left out.
3754
3945
  */
3755
- async signCadreInviteWithdrawals(keys, signMessage) {
3946
+ async signWithdrawals(table, keys, signMessage) {
3756
3947
  if (keys.length === 0) {
3757
3948
  return [];
3758
3949
  }
3759
- const withdrawn = await this.queryRevokedStamps('CadreInvite', false);
3950
+ const withdrawn = await this.queryRevokedStamps(table, false);
3760
3951
  const withdrawals = [];
3761
3952
  for (const key of keys) {
3762
- const stampId = await this.queryStampId('CadreInvite', key, false);
3953
+ const stampId = await this.queryStampId(table, key, false);
3763
3954
  if (stampId === null) {
3764
- log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
3955
+ log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
3765
3956
  continue;
3766
3957
  }
3767
3958
  if (withdrawn.has(stampId)) {
3768
- log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
3959
+ log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
3769
3960
  continue;
3770
3961
  }
3771
- const ref = { tableName: 'CadreInvite', rowKey: key, stampId };
3772
- withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
3962
+ const ref = { tableName: table, rowKey: key, stampId };
3963
+ withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
3773
3964
  }
3774
3965
  return withdrawals;
3775
3966
  }
3776
3967
  /**
3777
- * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
3778
- * when the invitation grants ownership, as an owner — with NO owner signature. One
3779
- * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
3780
- * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
3781
- * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
3782
- * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
3783
- * redemption and none can be held in reserve. The invitation must already be committed
3784
- * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
3968
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
3969
+ * with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
3970
+ * One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
3971
+ * the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
3972
+ * reads the peer row back, so the two are mutually bound to this exact redemption and
3973
+ * neither can be held in reserve. The invitation must already be committed
3974
+ * ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
3785
3975
  *
3786
3976
  * The two signatures are the holder's and the device's, minted on the device over
3787
3977
  * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
@@ -3790,18 +3980,17 @@ export class ControlDatabase {
3790
3980
  * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
3791
3981
  *
3792
3982
  * Idempotent for a retry after a dropped reply: when the device's row is already present
3793
- * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
3794
- * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
3795
- * owner-granting invitation is completed: the usage row then names the existing peer stamp.
3796
- * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
3797
- * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
3983
+ * nothing is written and no seat is spent — `alreadyMember: true`. The seat check
3984
+ * ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
3985
+ * redemption is answered `alreadyMember` rather than refused as exhausted.
3798
3986
  *
3799
3987
  * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
3800
3988
  * every read passes `retry: false` and every stamp is minted per attempt, so a
3801
3989
  * {@link lockedWithRetry} re-run starts clean.
3802
3990
  *
3803
3991
  * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
3804
- * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
3992
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
3993
+ * {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
3805
3994
  */
3806
3995
  async redeemCadreInvite(params) {
3807
3996
  this.ensureInitialized();
@@ -3812,57 +4001,37 @@ export class ControlDatabase {
3812
4001
  return this.mutateCadrePeer('cadre-invite-redeem', async () => {
3813
4002
  const invite = await this.queryCadreInvite(inviteKey, false);
3814
4003
  if (invite === null) {
3815
- throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
4004
+ throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
3816
4005
  }
3817
- const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
3818
- if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
4006
+ // A raw stamp read (queryStampId), so a physically present row counts; inside the locked
4007
+ // body, so never retried.
4008
+ const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
4009
+ if (seatedPeerStampId !== null) {
3819
4010
  log('redeemCadreInvite: %s already admitted; nothing written', peerId);
3820
- return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
4011
+ return { alreadyMember: true, peerStampId: seatedPeerStampId };
3821
4012
  }
3822
4013
  await this.assertCadreInviteSeatRemains(invite);
3823
- // Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
3824
- // the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
3825
- // attempt rather than re-presenting a stamp the usage row may already name.
3826
- const peerStampId = seated.peerStampId ?? generateStampId(peerId);
3827
- const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
4014
+ // The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
4015
+ // names the row it seats. Minted inside the body, so a re-run mints afresh over a
4016
+ // rolled-back attempt rather than re-presenting a stamp the usage row may already name.
4017
+ const peerStampId = generateStampId(peerId);
3828
4018
  const nowCanonical = await canonicalDatetime(this.db, nowMs);
3829
4019
  await this.inTransaction('cadre-invite-redeem', async () => {
3830
- if (seated.peerStampId === null) {
3831
- await this.db.exec(`
3832
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
3833
- with context OwnerKey = null, Signature = null
3834
- values (?, ?, ?, ?, null, ?, ?, null, ?)
3835
- `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
3836
- }
3837
- if (ownerStampId !== null && seated.ownerStampId === null) {
3838
- await this.db.exec(`
3839
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
3840
- with context OwnerKey = null, Signature = null
3841
- values (?, ?, ?, null, ?)
3842
- `, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
3843
- }
3844
4020
  await this.db.exec(`
3845
- insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
4021
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
4022
+ with context OwnerKey = null, Signature = null
4023
+ values (?, ?, ?, ?, null, ?, ?, null, ?)
4024
+ `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
4025
+ await this.db.exec(`
4026
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
3846
4027
  with context Now = ?
3847
- values (?, ?, ?, ?, ?, ?, ?, ?)
3848
- `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
4028
+ values (?, ?, ?, ?, ?, ?, ?)
4029
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
3849
4030
  });
3850
- log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
3851
- return { alreadyMember: false, peerStampId, ownerStampId };
4031
+ log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
4032
+ return { alreadyMember: false, peerStampId };
3852
4033
  });
3853
4034
  }
3854
- /**
3855
- * The rows a redemption would seat that are already present: the device's `CadrePeer`
3856
- * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
3857
- * reads ({@link queryStampId}), so a physically present row counts; inside the locked
3858
- * body, so never retried.
3859
- */
3860
- async readSeatedAdmission(peerId, peerKey, grantsOwner) {
3861
- return {
3862
- peerStampId: await this.queryStampId('CadrePeer', peerId, false),
3863
- ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
3864
- };
3865
- }
3866
4035
  /**
3867
4036
  * Refuse a redemption that would consume a seat the cadre invitation does not have, by
3868
4037
  * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
@@ -3870,15 +4039,225 @@ export class ControlDatabase {
3870
4039
  * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
3871
4040
  * committed count inside the locked body, so on a same-node race the loser is refused here.
3872
4041
  */
3873
- async assertCadreInviteSeatRemains(invite) {
3874
- if (invite.totalUses === null) {
4042
+ assertCadreInviteSeatRemains(invite) {
4043
+ return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
4044
+ }
4045
+ /** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
4046
+ async assertIssuedSeatRemains(row, countUsage) {
4047
+ if (row.totalUses === null) {
3875
4048
  return;
3876
4049
  }
3877
- const used = await this.countCadreInviteUsage(invite.key, false);
3878
- if (used >= invite.totalUses) {
3879
- throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
4050
+ const used = await countUsage(row.key);
4051
+ if (used >= row.totalUses) {
4052
+ throw new InvitationExhaustedError(row.key, used, row.totalUses);
3880
4053
  }
3881
4054
  }
4055
+ // ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
4056
+ /**
4057
+ * Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
4058
+ * grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
4059
+ * to seat an owner key the holder generates or already holds. No machine is admitted and no
4060
+ * peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
4061
+ * row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
4062
+ * the returned row is what the grant bundle carries, re-inserted verbatim by
4063
+ * {@link seatOwnerGrant} on a member that has not received it by replication.
4064
+ *
4065
+ * @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
4066
+ * caller minted the pair and hands the private half to the holder out of band
4067
+ * @param grant.label - the label every key this grant seats will carry
4068
+ * ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
4069
+ * issuer's choice, which the holder cannot change
4070
+ * @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
4071
+ * never. The short default lifetime is the node API's to apply, not this writer's.
4072
+ * @param grant.totalUses - how many redemptions it allows; omit for unlimited
4073
+ * @returns the row as stored, `expiresAt` in its canonical form
4074
+ */
4075
+ async insertOwnerGrant(grant, ownerKey, signMessage) {
4076
+ this.ensureInitialized();
4077
+ const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
4078
+ const signed = {
4079
+ key,
4080
+ label: ownerKeyLabel(grant.label),
4081
+ expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db, grant.expiresAtMs),
4082
+ totalUses: grant.totalUses ?? null,
4083
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
4084
+ };
4085
+ const row = {
4086
+ ...signed,
4087
+ issuerKey: ownerKey,
4088
+ issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
4089
+ };
4090
+ await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
4091
+ log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
4092
+ return row;
4093
+ }
4094
+ /**
4095
+ * Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
4096
+ * `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
4097
+ *
4098
+ * @returns `true` when this call inserted the row, `false` when it was already present.
4099
+ * @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
4100
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
4101
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
4102
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4103
+ */
4104
+ async seatOwnerGrant(row) {
4105
+ return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row), cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
4106
+ }
4107
+ /** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
4108
+ async execOwnerGrantInsert(row) {
4109
+ await this.db.exec(`
4110
+ insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4111
+ with context OwnerKey = ?, Signature = ?
4112
+ values (?, ?, ?, ?, ?, ?, ?)
4113
+ `, [
4114
+ row.issuerKey, row.issuerSig,
4115
+ row.key, row.label, row.expiresAt, row.totalUses,
4116
+ row.issuerKey, row.issuerSig, row.stampId,
4117
+ ]);
4118
+ }
4119
+ /**
4120
+ * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
4121
+ * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
4122
+ * away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
4123
+ */
4124
+ async queryOwnerGrant(key, retry = true) {
4125
+ this.ensureInitialized();
4126
+ for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
4127
+ return ownerGrantRowOf(row);
4128
+ }
4129
+ return null;
4130
+ }
4131
+ /**
4132
+ * Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
4133
+ * anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
4134
+ * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
4135
+ */
4136
+ async queryOwnerGrants(retry = true) {
4137
+ this.ensureInitialized();
4138
+ const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
4139
+ return rows.map(ownerGrantRowOf);
4140
+ }
4141
+ /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
4142
+ async queryOwnerGrantUsages(retry = true) {
4143
+ this.ensureInitialized();
4144
+ const rows = await this.readRows('select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage', undefined, 'owner-grant-usages', retry);
4145
+ return rows.map(ownerGrantUsageRowOf);
4146
+ }
4147
+ /**
4148
+ * How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
4149
+ * through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
4150
+ * {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
4151
+ */
4152
+ async countOwnerGrantUsage(key, retry = true) {
4153
+ return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
4154
+ }
4155
+ /**
4156
+ * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
4157
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
4158
+ * node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
4159
+ * when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
4160
+ * redemption's own write enforce every condition.
4161
+ */
4162
+ async isOwnerGrantLive(grant, nowMs = Date.now()) {
4163
+ this.ensureInitialized();
4164
+ const row = await this.queryOwnerGrant(grant.key) ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
4165
+ return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
4166
+ }
4167
+ /**
4168
+ * Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
4169
+ * and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
4170
+ */
4171
+ async listOwnerGrantStatuses(nowMs = Date.now()) {
4172
+ const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
4173
+ return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
4174
+ }
4175
+ /**
4176
+ * Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
4177
+ * stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
4178
+ * and for the same reason (every key the grant seated proves itself through the row;
4179
+ * `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
4180
+ * refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
4181
+ * holder's copy on any node holding the tombstone. Keys already seated stay owners;
4182
+ * removing one is {@link deleteOwnerKey}.
4183
+ *
4184
+ * Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
4185
+ * `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
4186
+ * withdrawal committed while alone is re-issued on cohort growth.
4187
+ */
4188
+ async withdrawOwnerGrant(key, ownerKey, signMessage) {
4189
+ return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
4190
+ }
4191
+ /**
4192
+ * Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
4193
+ * owner signature. One transaction writes the `OwnerKey` row (the grant branch of
4194
+ * `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
4195
+ * `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
4196
+ * row's `SeatedExists` reads the owner row back, so the two are bound to this exact
4197
+ * redemption and neither can be held in reserve. The grant must already be committed
4198
+ * ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
4199
+ * admitted and no peer id is named.
4200
+ *
4201
+ * The two signatures are minted where the private keys are (the grant's and the seated
4202
+ * key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
4203
+ * machine only verifies them, by writing the row.
4204
+ *
4205
+ * Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
4206
+ * `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
4207
+ * that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
4208
+ * the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
4209
+ * exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
4210
+ * except on a machine that still holds its retired row, which refuses by name
4211
+ * ({@link liveOwnerKeyStamp}).
4212
+ *
4213
+ * Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
4214
+ * inside the locked body every read passes `retry: false` and the stamp is minted per
4215
+ * attempt, so a {@link lockedWithRetry} re-run starts clean.
4216
+ *
4217
+ * @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
4218
+ * the schema's generic `Authorized` refusal); its `token` is the grant key.
4219
+ * {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
4220
+ */
4221
+ async redeemOwnerGrant(params) {
4222
+ this.ensureInitialized();
4223
+ const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
4224
+ const nowMs = params.nowMs ?? Date.now();
4225
+ log('Redeeming owner grant %s for %s', grantKey, seatedKey);
4226
+ return this.mutateCadrePeer('owner-grant-redeem', async () => {
4227
+ const grant = await this.queryOwnerGrant(grantKey, false);
4228
+ if (grant === null) {
4229
+ throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
4230
+ }
4231
+ const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
4232
+ if (heldStamp !== null) {
4233
+ log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
4234
+ return { alreadyOwner: true, seatedStampId: heldStamp };
4235
+ }
4236
+ await this.assertOwnerGrantSeatRemains(grant);
4237
+ // Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
4238
+ // than re-presenting a stamp the usage row may already name.
4239
+ const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
4240
+ const nowCanonical = await canonicalDatetime(this.db, nowMs);
4241
+ await this.inTransaction('owner-grant-redeem', async () => {
4242
+ await this.db.exec(`
4243
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
4244
+ with context OwnerKey = null, Signature = null
4245
+ values (?, ?, ?, ?, null, ?)
4246
+ `, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
4247
+ await this.db.exec(`
4248
+ insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
4249
+ with context Now = ?
4250
+ values (?, ?, ?, ?, ?, ?)
4251
+ `, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
4252
+ });
4253
+ log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
4254
+ return { alreadyOwner: false, seatedStampId };
4255
+ });
4256
+ }
4257
+ /** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
4258
+ assertOwnerGrantSeatRemains(grant) {
4259
+ return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
4260
+ }
3882
4261
  /**
3883
4262
  * Close the database and cleanup resources.
3884
4263
  *