@serfab/cadre-core 1.15.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 +16 -7
  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 +131 -531
  151. package/src/cadre-join-request.ts +188 -167
  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 +102 -5
  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
@@ -7,13 +7,15 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
7
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
8
  import type { Libp2p } from '@libp2p/interface';
9
9
  import type { IRepo } from '@optimystic/db-core';
10
- import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, SeedPeer, MachineRemovalResult } from './types.js';
11
- import { CONTROL_SCHEMA } from './control-schema.js';
10
+ import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
11
+ import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, controlSchemaFileDeclaration } from './control-schema.js';
12
+ import { refuseIncompatibleControlStore } from './control-schema-version.js';
12
13
  import { canonicalDatetime } from './canonical-datetime.js';
13
- import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
14
- import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDomain, ControlAction, CadreInviteSignedFields } from './control-authorization.js';
15
- import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
16
- import { verifyCadreInviteRow } from './peer-authorization.js';
14
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
15
+ import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDomain, ControlAction, CadreInviteSignedFields, OwnerGrantSignedFields } from './control-authorization.js';
16
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
17
+ import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
18
+ import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
17
19
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
18
20
  import type { ControlWriteRetryOptions } from './control-write-retry.js';
19
21
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
@@ -97,13 +99,14 @@ export class FormationAbortedError extends Error {
97
99
  /**
98
100
  * The invitation's seat budget is already spent: the count of recorded usage rows for the
99
101
  * invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
100
- * is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
101
- * redeemed against.
102
+ * is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
103
+ * table the caller redeemed against.
102
104
  *
103
105
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
104
106
  * the write, inside {@link ControlDatabase.redeemInvitation} /
105
107
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
106
- * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
108
+ * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
109
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
107
110
  * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
108
111
  * pass — and off nothing else. It exists because
109
112
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
@@ -148,8 +151,51 @@ export class CadreInviteIssuerUnknownError extends Error {
148
151
  }
149
152
  }
150
153
 
151
- /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
152
- const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
154
+ /**
155
+ * {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
156
+ * grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
157
+ * `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
158
+ * Its own class rather than a shared one so a caller that handles invitation and grant
159
+ * bundles can tell by `instanceof` which credential was refused.
160
+ */
161
+ export class OwnerGrantIssuerUnknownError extends Error {
162
+ constructor(
163
+ readonly grantKey: string,
164
+ readonly issuerKey: string,
165
+ options?: { cause?: unknown }
166
+ ) {
167
+ super(
168
+ `Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`,
169
+ options
170
+ );
171
+ this.name = 'OwnerGrantIssuerUnknownError';
172
+ }
173
+ }
174
+
175
+ /**
176
+ * An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
177
+ * yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
178
+ * insert refused by a cohort member already holding the row, and a refreshed read here still
179
+ * finds nothing; or the redemption that follows the seat finds no row. It clears once this
180
+ * node's store catches up, which is why the redemption paths answer it with the retryable
181
+ * `conflict` code.
182
+ */
183
+ export class IssuedRowNotHereYetError extends Error {
184
+ constructor(
185
+ readonly table: IssuedTable,
186
+ readonly key: string,
187
+ options?: { cause?: unknown }
188
+ ) {
189
+ super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
190
+ this.name = 'IssuedRowNotHereYetError';
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
196
+ * wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
197
+ */
198
+ const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
153
199
 
154
200
  /**
155
201
  * What a recorded `FormationUsage` row came out as.
@@ -370,6 +416,51 @@ export function cadreInviteConsentMessage(partyId: string, fields: CadreInviteRe
370
416
  ]);
371
417
  }
372
418
 
419
+ /**
420
+ * The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
421
+ * digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
422
+ * nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
423
+ * `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
424
+ * canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
425
+ * the reason on {@link cadreInviteAddMessage}.
426
+ */
427
+ export function ownerGrantAddMessage(partyId: string, row: OwnerGrantSignedFields): Uint8Array {
428
+ return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
429
+ }
430
+
431
+ /** The fields both owner-grant redemption signatures cover; see {@link ownerGrantRedeemMessage}. */
432
+ export interface OwnerGrantRedemptionFields {
433
+ grantKey: string;
434
+ /** The single-use nonce the holder minted for this redemption (`generateStampId`). */
435
+ usageStampId: string;
436
+ /** The owner key being seated (base64url ed25519). */
437
+ seatedKey: string;
438
+ }
439
+
440
+ /**
441
+ * The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
442
+ * it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
443
+ * against the grant's public key. Binding the seated key and the nonce means a captured
444
+ * redemption can seat no other key and spend no other use.
445
+ */
446
+ export function ownerGrantRedeemMessage(partyId: string, fields: OwnerGrantRedemptionFields): Uint8Array {
447
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
448
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
449
+ ]);
450
+ }
451
+
452
+ /**
453
+ * The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
454
+ * `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
455
+ * fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
456
+ * signature can stand in for the other.
457
+ */
458
+ export function ownerGrantConsentMessage(partyId: string, fields: OwnerGrantRedemptionFields): Uint8Array {
459
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
460
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
461
+ ]);
462
+ }
463
+
373
464
  /**
374
465
  * Minimal interface for the CollectionFactory returned by the optimystic plugin.
375
466
  * We only need the methods we actually use.
@@ -418,14 +509,23 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
418
509
  JoinRequest: 'Id',
419
510
  DeviceToken: 'PeerId',
420
511
  CadreInvite: 'Key',
512
+ OwnerGrant: 'Key',
421
513
  };
422
514
 
423
515
  /**
424
- * A guarded table an owner may remove a row from. `CadreInvite` is excluded by design — its
425
- * rows are never deleted (`NoDelete`), and withdrawing one is a tombstone alone
426
- * ({@link ControlDatabase.withdrawCadreInvite}).
516
+ * A guarded table of owner-issued credentials: signed rows that travel inside a bundle, are
517
+ * seated verbatim by any member ({@link ControlDatabase.seatCadreInvite},
518
+ * {@link ControlDatabase.seatOwnerGrant}), and are never deleted (`NoDelete`) because the
519
+ * rows they admitted prove themselves through them. Withdrawing one is a tombstone alone
520
+ * ({@link ControlDatabase.withdrawCadreInvite}, {@link ControlDatabase.withdrawOwnerGrant}).
427
521
  */
428
- type RemovableTable = Exclude<RevocableTable, 'CadreInvite'>;
522
+ type IssuedTable = Extract<RevocableTable, 'CadreInvite' | 'OwnerGrant'>;
523
+
524
+ /** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
525
+ const ISSUED_TABLE_REASON: Readonly<Record<IssuedTable, string>> = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
526
+
527
+ /** A guarded table an owner may remove a row from: every revocable table but the {@link IssuedTable}s. */
528
+ type RemovableTable = Exclude<RevocableTable, IssuedTable>;
429
529
 
430
530
  /**
431
531
  * One row incarnation's owner-signed removal, ready for {@link ControlDatabase.execGuardedRemoval}:
@@ -443,7 +543,7 @@ interface GuardedRemoval {
443
543
  /**
444
544
  * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
445
545
  * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
446
- * tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
546
+ * tombstone writer (a guarded removal, an issued row's withdrawal, the ledger marker) so the
447
547
  * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
448
548
  * `revocationDigest`.
449
549
  */
@@ -455,42 +555,85 @@ function revocationTombstoneMessage(partyId: string, ref: { tableName: string; r
455
555
  * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
456
556
  * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
457
557
  */
458
- function ownerKeyAddMessage(partyId: string, key: string, stampId: string): Uint8Array {
459
- return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
558
+ function ownerKeyAddMessage(partyId: string, key: string, label: string | null, stampId: string): Uint8Array {
559
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
560
+ }
561
+
562
+ /**
563
+ * An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
564
+ * {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
565
+ * Idempotent, so a caller that checks before opening an owner approval can pass the result on.
566
+ */
567
+ export function ownerKeyLabel(label: string | null | undefined): string | null {
568
+ const trimmed = label?.trim();
569
+ if (!trimmed) {
570
+ return null;
571
+ }
572
+ if (trimmed.length > OWNER_KEY_MAX_LABEL) {
573
+ throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
574
+ }
575
+ return trimmed;
576
+ }
577
+
578
+ /**
579
+ * Signs one owner digest's raw bytes (no pre-hash) and returns a base64url Ed25519 signature:
580
+ * the callback every owner-signed writer takes. An `OwnerSign` from an open
581
+ * `OwnerSigner.approve` fits it, and so does a synchronous signer.
582
+ *
583
+ * NOTE: writers that read the stamp they sign do so inside the write lock, so the lock is held
584
+ * across these calls, and a `lockedWithRetry` re-run signs again inside the same approval.
585
+ * Free for a software signer. If a prompting signer ever holds the lock long enough to stall
586
+ * other control writes, sign outside the lock and re-check the stamp inside it.
587
+ */
588
+ export type ControlSign = (message: Uint8Array) => string | Promise<string>;
589
+
590
+ /** What an owner asks {@link ControlDatabase.insertCadreInvite} (or {@link ControlDatabase.signCadreInvite}) to sign. */
591
+ export interface CadreInviteFields {
592
+ /** The invitation public key (base64url ed25519), the row's primary key. */
593
+ key: string;
594
+ /** The device the invitation is for, or null for whoever redeems first. */
595
+ peerId?: string | null;
596
+ /** Epoch ms after which it cannot be redeemed; omit for never. */
597
+ expiresAtMs?: number | null;
598
+ /** How many redemptions it allows; omit for unlimited. */
599
+ totalUses?: number | null;
460
600
  }
461
601
 
462
- function signGuardedRemoval(
602
+ async function signGuardedRemoval(
463
603
  partyId: string,
464
604
  tableName: RemovableTable,
465
605
  rowKey: string,
466
606
  stampId: string,
467
- signMessage: (message: Uint8Array) => string
468
- ): GuardedRemoval {
607
+ signMessage: ControlSign
608
+ ): Promise<GuardedRemoval> {
469
609
  const ref = { tableName, rowKey, stampId };
470
610
  return {
471
611
  ref,
472
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
473
- revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
612
+ signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
613
+ revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
474
614
  };
475
615
  }
476
616
 
477
617
  /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
478
618
  type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
479
619
 
480
- /** The control tables keyed by an `Id` column whose repeat insert a caller may classify. */
481
- type IdKeyedTable = StrandIdTable | Extract<RevocableTable, 'JoinRequest'>;
620
+ /** The control tables whose repeat insert of a held key a caller may classify. */
621
+ type KeyConflictTable = StrandIdTable | IssuedTable | Extract<RevocableTable, 'JoinRequest'>;
482
622
 
483
623
  /**
484
- * The rejection a second insert of an already-seated id into `table` produces, as the
624
+ * The rejection a second insert of an already-seated key into `table` produces, as the
485
625
  * optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
486
- * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
626
+ * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
627
+ * naming the table's {@link GUARDED_KEY_COLUMN}.
487
628
  *
488
629
  * Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
489
- * only the primary key can collide on a repeat insert. Matching the column explicitly
490
- * keeps any OTHER uniqueness failure out of the caller's idempotency branch.
630
+ * only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
631
+ * stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
632
+ * Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
633
+ * idempotency branch.
491
634
  */
492
- function idConflictPattern(table: IdKeyedTable): RegExp {
493
- return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
635
+ function keyConflictPattern(table: KeyConflictTable): RegExp {
636
+ return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
494
637
  }
495
638
 
496
639
  /**
@@ -509,7 +652,17 @@ function idConflictPattern(table: IdKeyedTable): RegExp {
509
652
  * reword reddens there.
510
653
  */
511
654
  export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean {
512
- return errorChainMatches(error, idConflictPattern(table));
655
+ return errorChainMatches(error, keyConflictPattern(table));
656
+ }
657
+
658
+ /**
659
+ * Did a seat of an issued row fail because a cohort member already holds a row under its key?
660
+ * {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
661
+ * on a node whose store lags the cohort: its guard read missed the row, and the member that
662
+ * checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
663
+ */
664
+ export function isIssuedRowKeyConflict(error: unknown, table: IssuedTable): boolean {
665
+ return errorChainMatches(error, keyConflictPattern(table));
513
666
  }
514
667
 
515
668
  /**
@@ -522,7 +675,7 @@ export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolea
522
675
  * until the next reap pass.
523
676
  */
524
677
  export function isPendingJoinConflict(error: unknown): boolean {
525
- return errorChainMatches(error, idConflictPattern('JoinRequest'));
678
+ return errorChainMatches(error, keyConflictPattern('JoinRequest'));
526
679
  }
527
680
 
528
681
  /**
@@ -569,7 +722,7 @@ export const REVOCATION_LEDGER_MARKER = {
569
722
 
570
723
  /**
571
724
  * The rejection a second insert of the ledger marker produces: the optimystic vtab's
572
- * primary-key wording (see {@link idConflictPattern}), naming both columns of
725
+ * primary-key wording (see {@link keyConflictPattern}), naming both columns of
573
726
  * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
574
727
  * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
575
728
  * has no other unique constraint.
@@ -588,42 +741,33 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
588
741
 
589
742
  /**
590
743
  * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
591
- * redemption reply, carries.
744
+ * redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
745
+ * redemption; it contributes an entry only through its own `CadrePeer` row.
592
746
  *
593
- * Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
594
- * Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
595
- * key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
596
- * `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
597
- * or unparsable id yields a non-owner rather than failing the whole projection.
598
- *
599
- * NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
600
- * the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
601
- * peers first — not a trust decision, and the receiver re-derives real trust from its own
602
- * anchor. So a polluted table costs at most a wasted dial, while anchoring here would
603
- * silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
604
- * anything the receiver TRUSTS, move it to the anchor.
747
+ * A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
748
+ * receiver at that moment, since it is delivering the seed or the reply, or an always-on
749
+ * machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
750
+ * reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
751
+ * own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
752
+ * machine misdeclaring one costs the receiver a wasted dial at most.
605
753
  */
606
- export function projectSeedPeers(rows: readonly CadrePeerRow[], ownerKeys: ReadonlySet<string>): SeedPeer[] {
607
- return rows.map(({ peerId, multiaddr }) => {
608
- const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
609
- const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
610
- return {
611
- peerId,
612
- multiaddrs: multiaddr ? multiaddr.split(',') : [],
613
- isOwner,
614
- ...(isOwner ? { publicKey: pubKeyB64 } : {}),
615
- };
616
- });
754
+ export function projectSeedPeers(rows: readonly CadrePeerRow[], minterPeerId: string): SeedPeer[] {
755
+ return rows.map(({ peerId, multiaddr, profile }) => ({
756
+ peerId,
757
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
758
+ preferred: peerId === minterPeerId || profile === 'storage',
759
+ }));
617
760
  }
618
761
 
619
762
  /**
620
- * The conditions a cadre invitation must still meet to be redeemed, short of its use count:
621
- * not withdrawn, unexpired (`expiresAt <= now` is expired, as the redemption refuses it),
622
- * and issued by a current owner. Shared by {@link ControlDatabase.hasLiveCadreInvite},
623
- * {@link ControlDatabase.isCadreInviteLive} and {@link ControlDatabase.listCadreInviteStatuses}
624
- * so they cannot drift from each other, or from `CadreInviteUsage.Authorized`.
763
+ * The conditions an issued credential (a cadre invitation or an owner grant) must still meet
764
+ * to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
765
+ * expired, as the redemption refuses it), and issued by a current owner. Shared by
766
+ * {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
767
+ * {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
768
+ * drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
625
769
  */
626
- function cadreInviteStillOpen(
770
+ function issuedRowStillOpen(
627
771
  row: { stampId: string; issuerKey: string; expiresAt: string | null },
628
772
  withdrawn: ReadonlySet<string>,
629
773
  owners: ReadonlySet<string>,
@@ -647,8 +791,8 @@ function cadreInviteStillOpen(
647
791
  * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
648
792
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
649
793
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
650
- * party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
651
- * withdrawal over a row that stays), so there is nothing to reap.
794
+ * party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
795
+ * tombstones are withdrawals over rows that stay), so there is nothing to reap.
652
796
  */
653
797
  export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'] as const satisfies readonly RevocableTable[];
654
798
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
@@ -656,7 +800,7 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
656
800
  /**
657
801
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
658
802
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
659
- * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
803
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
660
804
  * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
661
805
  * log line per skip would be per-pass noise.
662
806
  */
@@ -664,18 +808,17 @@ const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(
664
808
  const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
665
809
 
666
810
  /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
667
- const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
811
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
668
812
 
669
813
  /**
670
814
  * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
671
815
  * engine-canonical string it reads back as — the form the `'add'` signature covers — never
672
- * parsed to ms here; `GrantsOwner` is the schema's 0/1 integer.
816
+ * parsed to ms here.
673
817
  */
674
818
  function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
675
819
  return {
676
820
  key: row.Key as string,
677
821
  peerId: (row.PeerId as string | null) ?? null,
678
- grantsOwner: Number(row.GrantsOwner) === 1,
679
822
  expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
680
823
  totalUses: (row.TotalUses as number | null) ?? null,
681
824
  issuerKey: row.IssuerKey as string,
@@ -692,12 +835,47 @@ function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageR
692
835
  peerId: row.PeerId as string,
693
836
  peerKey: row.PeerKey as string,
694
837
  peerStampId: row.PeerStampId as string,
695
- ownerStampId: (row.OwnerStampId as string | null) ?? null,
696
838
  inviteSig: row.InviteSig as string,
697
839
  peerSig: row.PeerSig as string,
698
840
  };
699
841
  }
700
842
 
843
+ /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; callers append their `where`. */
844
+ const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
845
+
846
+ /** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
847
+ function ownerGrantRowOf(row: Record<string, SqlValue>): OwnerGrantRow {
848
+ return {
849
+ key: row.Key as string,
850
+ label: (row.Label as string | null) ?? null,
851
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
852
+ totalUses: (row.TotalUses as number | null) ?? null,
853
+ issuerKey: row.IssuerKey as string,
854
+ issuerSig: row.IssuerSig as string,
855
+ stampId: row.StampId as string,
856
+ };
857
+ }
858
+
859
+ /** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
860
+ function ownerGrantUsageRowOf(row: Record<string, SqlValue>): OwnerGrantUsageRow {
861
+ return {
862
+ usageStampId: row.UsageStampId as string,
863
+ grantKey: row.GrantKey as string,
864
+ seatedKey: row.SeatedKey as string,
865
+ seatedStampId: row.SeatedStampId as string,
866
+ grantSig: row.GrantSig as string,
867
+ ownerSig: row.OwnerSig as string,
868
+ };
869
+ }
870
+
871
+ /** One issued row's standing, as {@link ControlDatabase.listCadreInviteStatuses} and {@link ControlDatabase.listOwnerGrantStatuses} report it. */
872
+ interface IssuedRowStanding<Row> {
873
+ row: Row;
874
+ live: boolean;
875
+ withdrawn: boolean;
876
+ usesRecorded: number;
877
+ }
878
+
701
879
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
702
880
  function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
703
881
  return {
@@ -705,9 +883,23 @@ function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
705
883
  MemberPrivateKey: row.MemberPrivateKey as string | null,
706
884
  Type: row.Type as 'o' | 'c',
707
885
  FounderOwnerKey: null,
886
+ FounderPeerId: null,
708
887
  };
709
888
  }
710
889
 
890
+ /** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
891
+ function strandRow(row: Record<string, SqlValue>): StrandRow {
892
+ return {
893
+ Id: row.Id as string,
894
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
895
+ Type: row.Type as 'o' | 'c',
896
+ FounderOwnerKey: row.FounderOwnerKey as string | null,
897
+ FounderPeerId: row.FounderPeerId as string | null,
898
+ };
899
+ }
900
+
901
+ const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
902
+
711
903
  /** A `FormationInvite` row as {@link ControlDatabase.queryFormationInvite} reads it. */
712
904
  export interface FormationInviteRow {
713
905
  token: string;
@@ -805,13 +997,13 @@ interface SignedStatement {
805
997
  * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
806
998
  * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
807
999
  */
808
- function signJoinOutcomeInsert(
1000
+ async function signJoinOutcomeInsert(
809
1001
  partyId: string,
810
1002
  requestStampId: string,
811
1003
  outcome: JoinOutcome,
812
1004
  ownerKey: string,
813
- signMessage: (message: Uint8Array) => string
814
- ): SignedStatement {
1005
+ signMessage: ControlSign
1006
+ ): Promise<SignedStatement> {
815
1007
  const at = String(outcome.RecordedAt);
816
1008
  const { table, sql, fields, values } = outcome.kind === 'joined'
817
1009
  ? {
@@ -824,7 +1016,7 @@ function signJoinOutcomeInsert(
824
1016
  fields: [requestStampId, at, outcome.Code, outcome.Reason],
825
1017
  values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
826
1018
  };
827
- const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
1019
+ const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
828
1020
  return { sql, params: [ownerKey, signature, ...values] };
829
1021
  }
830
1022
 
@@ -848,9 +1040,6 @@ export type MembershipChangeListener = (reason: string) => Promise<void>;
848
1040
  */
849
1041
  export type RevokedRowRef = Pick<RevocationRow, 'tableName' | 'rowKey' | 'stampId'>;
850
1042
 
851
- /** What {@link ControlDatabase.removeMachine} wrote: `CadreNode.removeMachine`'s result, less the re-vouches it made first. */
852
- export type MachineRemovalWrites = Pick<MachineRemovalResult, 'removedPeer' | 'removedOwnerKey' | 'removedDeviceToken' | 'withdrawnInvitations'>;
853
-
854
1043
  /**
855
1044
  * Notified after a guarded-table delete — and the `CadreControl.Revocation`
856
1045
  * tombstone it writes in the same transaction — has COMMITTED.
@@ -878,6 +1067,30 @@ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
878
1067
  */
879
1068
  export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
880
1069
 
1070
+ /** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
1071
+ async function readSchemaFile(schemaPath: string): Promise<string> {
1072
+ if (typeof process === 'undefined' || !process.versions?.node) {
1073
+ throw new Error(
1074
+ 'Loading schema from file is not supported in React Native. ' +
1075
+ 'Remove the schemaPath option to use the embedded schema instead.'
1076
+ );
1077
+ }
1078
+ try {
1079
+ // Use require to conditionally load fs only in Node.js.
1080
+ // This won't be bundled by React Native's Metro bundler — a dynamic
1081
+ // import() would be statically picked up by Metro, so require is
1082
+ // intentional here (cross-platform constraint, not lazy CommonJS).
1083
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1084
+ const fs = require('fs/promises');
1085
+ return await fs.readFile(schemaPath, 'utf-8');
1086
+ } catch (error) {
1087
+ throw new Error(
1088
+ `Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
1089
+ { cause: error }
1090
+ );
1091
+ }
1092
+ }
1093
+
881
1094
  export interface ControlDatabaseConfig {
882
1095
  /**
883
1096
  * Party ID for the control network. Also the party every owner and peer signature over a
@@ -890,6 +1103,8 @@ export interface ControlDatabaseConfig {
890
1103
  * Optional path to the control schema file.
891
1104
  * If not provided, uses the embedded schema for cross-platform compatibility.
892
1105
  * Only use this if you need to override the default schema (e.g., for testing).
1106
+ * The file has the shape of `schemas/control.qsql`: one `declare schema CadreControl`
1107
+ * followed by one plain `apply schema CadreControl` ({@link controlSchemaFileDeclaration}).
893
1108
  */
894
1109
  schemaPath?: string;
895
1110
  /** Libp2p node for the control network (injected) */
@@ -1045,49 +1260,36 @@ export class ControlDatabase {
1045
1260
  }
1046
1261
 
1047
1262
  private async loadSchema(): Promise<void> {
1048
- let schemaContent: string;
1049
-
1263
+ let declaration: string;
1050
1264
  if (this.config.schemaPath) {
1051
- // Load from file if explicitly provided (for testing or custom schemas)
1052
- // This only works in Node.js environments
1053
1265
  log('Loading schema from file: %s', this.config.schemaPath);
1054
-
1055
- // Check if we're in a Node.js environment
1056
- if (typeof process !== 'undefined' && process.versions?.node) {
1057
- try {
1058
- // Use require to conditionally load fs only in Node.js.
1059
- // This won't be bundled by React Native's Metro bundler — a dynamic
1060
- // import() would be statically picked up by Metro, so require is
1061
- // intentional here (cross-platform constraint, not lazy CommonJS).
1062
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1063
- const fs = require('fs/promises');
1064
- schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
1065
- } catch (error) {
1066
- throw new Error(
1067
- `Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
1068
- { cause: error }
1069
- );
1070
- }
1071
- } else {
1072
- throw new Error(
1073
- 'Loading schema from file is not supported in React Native. ' +
1074
- 'Remove the schemaPath option to use the embedded schema instead.'
1075
- );
1076
- }
1266
+ declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
1077
1267
  } else {
1078
- // Use embedded schema for cross-platform compatibility
1079
1268
  log('Using embedded control schema');
1080
- schemaContent = CONTROL_SCHEMA;
1269
+ declaration = CONTROL_SCHEMA_DECLARATION;
1081
1270
  }
1082
1271
 
1272
+ // Declaring runs no DDL; it only records the declaration the check and the apply both read.
1273
+ await this.db!.exec(declaration);
1274
+ // A warm store this build cannot apply to without losing data is refused HERE, before the
1275
+ // apply runs anything: the optimystic module refuses most changes to an existing table
1276
+ // part-way through an apply, and a dropped table takes its rows. Migrating a store in place
1277
+ // (control-store-migrates-on-start) widens this check to let catalog-only changes through.
1278
+ await refuseIncompatibleControlStore(this.db!, this.config.partyId);
1279
+ await this.applyControlSchema();
1280
+ log('Schema loaded and executed');
1281
+ }
1282
+
1283
+ /** Apply the declared control schema, retrying transient cohort failures. */
1284
+ private async applyControlSchema(): Promise<void> {
1083
1285
  // Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
1084
1286
  // retry every other control write does ({@link lockedWithRetry}). Every CadreControl
1085
- // table is optimystic-backed (the default vtab was routed to optimystic just above),
1287
+ // table is optimystic-backed (`initialize` routes the default vtab to optimystic),
1086
1288
  // so each `create table` needs a super-majority of the party's peers to answer. On a
1087
1289
  // node joining a party that already has two live members a single unanswered peer
1088
1290
  // during any one create otherwise kills startup outright.
1089
1291
  //
1090
- // Re-running the WHOLE `exec` after a partial failure is safe, and this is the
1292
+ // Re-running the apply after a partial failure is safe, and this is the
1091
1293
  // non-obvious part:
1092
1294
  //
1093
1295
  // - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
@@ -1095,11 +1297,6 @@ export class ControlDatabase {
1095
1297
  // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
1096
1298
  // A table the live catalog already lists generates no statements at all, which is what
1097
1299
  // makes an apply over an already-complete schema (a warm start) a no-op.
1098
- // NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
1099
- // `alter table ... drop constraint`, which the optimystic module refuses, so a store
1100
- // written by a build with older constraint text fails here on every start (policy:
1101
- // recreate, docs/architecture.md after the Control Network table). If backwards
1102
- // compatibility becomes a requirement, this apply is the migration site.
1103
1300
  // - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
1104
1301
  // undo journal; when a step fails it runs that journal in reverse, re-renders the
1105
1302
  // catalog, compares it against a fingerprint taken BEFORE the apply, and only then
@@ -1132,8 +1329,7 @@ export class ControlDatabase {
1132
1329
  // also what lets schema init absorb optimystic's self-coordination grace refusal, which
1133
1330
  // is unsafe for control writes in general. Why, on
1134
1331
  // `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
1135
- await this.lockedWithRetry(() => this.db!.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
1136
- log('Schema loaded and executed');
1332
+ await this.lockedWithRetry(() => this.db!.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
1137
1333
  }
1138
1334
 
1139
1335
  /**
@@ -1315,16 +1511,8 @@ export class ControlDatabase {
1315
1511
  */
1316
1512
  async queryStrands(): Promise<StrandRow[]> {
1317
1513
  this.ensureInitialized();
1318
- const results: StrandRow[] = [];
1319
- for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
1320
- results.push({
1321
- Id: row.Id as string,
1322
- MemberPrivateKey: row.MemberPrivateKey as string | null,
1323
- Type: row.Type as 'o' | 'c',
1324
- FounderOwnerKey: row.FounderOwnerKey as string | null,
1325
- });
1326
- }
1327
- return results;
1514
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
1515
+ return rows.map(strandRow);
1328
1516
  }
1329
1517
 
1330
1518
  /**
@@ -1335,25 +1523,14 @@ export class ControlDatabase {
1335
1523
  */
1336
1524
  async queryStrand(strandId: string): Promise<StrandRow | null> {
1337
1525
  this.ensureInitialized();
1338
- for (const row of await this.readRows(
1339
- 'select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?',
1340
- [strandId],
1341
- 'strand'
1342
- )) {
1343
- return {
1344
- Id: row.Id as string,
1345
- MemberPrivateKey: row.MemberPrivateKey as string | null,
1346
- Type: row.Type as 'o' | 'c',
1347
- FounderOwnerKey: row.FounderOwnerKey as string | null,
1348
- };
1349
- }
1350
- return null;
1526
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
1527
+ return rows.length === 0 ? null : strandRow(rows[0]!);
1351
1528
  }
1352
1529
 
1353
1530
  /**
1354
1531
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
1355
1532
  * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
1356
- * never founds, so `FounderOwnerKey` is always null. Read raw, like {@link queryStrands}:
1533
+ * never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
1357
1534
  * no retired-stamp filter.
1358
1535
  */
1359
1536
  async queryJoinedStrands(): Promise<StrandRow[]> {
@@ -1452,15 +1629,17 @@ export class ControlDatabase {
1452
1629
  * Idempotent genesis: insert `key` as the founding owner key only when
1453
1630
  * the party has none yet. Returns true if it inserted, false if an owner
1454
1631
  * key already existed (so a repeat `--owner` start is a no-op).
1632
+ *
1633
+ * @param label - the user's name for the key ({@link insertOwnerKey}).
1455
1634
  */
1456
- async ensureOwnerKey(key: string): Promise<boolean> {
1635
+ async ensureOwnerKey(key: string, label?: string): Promise<boolean> {
1457
1636
  this.ensureInitialized();
1458
1637
  const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
1459
1638
  if (await this.hasOwnerKey()) {
1460
1639
  log('Owner key already present; skipping genesis insert');
1461
1640
  return false;
1462
1641
  }
1463
- await this.insertOwnerKey(trimmed);
1642
+ await this.insertOwnerKey(trimmed, label);
1464
1643
  return true;
1465
1644
  }
1466
1645
 
@@ -1491,8 +1670,9 @@ export class ControlDatabase {
1491
1670
  }
1492
1671
 
1493
1672
  /**
1494
- * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1495
- * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1673
+ * Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
1674
+ * the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
1675
+ * listing shows (`CadreNode.listOwnerKeys`). Rows whose
1496
1676
  * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1497
1677
  * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1498
1678
  * reads through this. A row with no stamp (the schema permits it; no writer produces one)
@@ -1512,7 +1692,7 @@ export class ControlDatabase {
1512
1692
  this.ensureInitialized();
1513
1693
  const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1514
1694
  const rows: OwnerKeyRow[] = [];
1515
- const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1695
+ const sql = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1516
1696
  for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1517
1697
  const stampId = (row.StampId as string | null) ?? null;
1518
1698
  if (stampId === null) {
@@ -1524,6 +1704,7 @@ export class ControlDatabase {
1524
1704
  }
1525
1705
  rows.push({
1526
1706
  key: row.Key as string,
1707
+ label: (row.Label as string | null) ?? null,
1527
1708
  stampId,
1528
1709
  vouchOwner: (row.VouchOwner as string | null) ?? null,
1529
1710
  vouchSig: (row.VouchSig as string | null) ?? null,
@@ -1600,16 +1781,16 @@ export class ControlDatabase {
1600
1781
 
1601
1782
  /**
1602
1783
  * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
1603
- * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
1604
- * the replicated `OwnerKey` table ({@link projectSeedPeers}).
1784
+ * row with its addresses, the minter and every always-on machine marked preferred
1785
+ * ({@link projectSeedPeers}).
1605
1786
  *
1606
1787
  * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1607
1788
  * member's addresses are never packed into a seed or handed to a redeeming device as a
1608
1789
  * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1609
1790
  * its exits.
1610
1791
  */
1611
- async querySeedPeers(): Promise<SeedPeer[]> {
1612
- return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
1792
+ async querySeedPeers(minterPeerId: string): Promise<SeedPeer[]> {
1793
+ return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
1613
1794
  }
1614
1795
 
1615
1796
  /**
@@ -1978,9 +2159,13 @@ export class ControlDatabase {
1978
2159
 
1979
2160
  /**
1980
2161
  * Insert the initial owner key (bootstrap - no existing owners required)
2162
+ *
2163
+ * @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
2164
+ * characters once trimmed; blank is stored as none.
1981
2165
  */
1982
- async insertOwnerKey(key: string): Promise<void> {
2166
+ async insertOwnerKey(key: string, label?: string): Promise<void> {
1983
2167
  this.ensureInitialized();
2168
+ const storedLabel = ownerKeyLabel(label);
1984
2169
  log('Inserting owner key: %s', key);
1985
2170
 
1986
2171
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
@@ -1993,10 +2178,10 @@ export class ControlDatabase {
1993
2178
  // optimystic `StampId()` SQL function.
1994
2179
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1995
2180
  await this.execWrite(`
1996
- insert into CadreControl.OwnerKey (Key, StampId)
2181
+ insert into CadreControl.OwnerKey (Key, Label, StampId)
1997
2182
  with context OwnerKey = null, Signature = null
1998
- values (?, ?)
1999
- `, [key, stampId], 'owner-key-insert');
2183
+ values (?, ?, ?)
2184
+ `, [key, storedLabel, stampId], 'owner-key-insert');
2000
2185
  log('Owner key inserted');
2001
2186
  }
2002
2187
 
@@ -2004,57 +2189,85 @@ export class ControlDatabase {
2004
2189
  * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
2005
2190
  * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
2006
2191
  * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
2007
- * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
2008
- * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
2192
+ * (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
2193
+ * reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
2009
2194
  *
2010
2195
  * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
2011
2196
  * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
2012
2197
  * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
2013
2198
  *
2014
- * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
2015
- * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
2016
- * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
2017
- * would collide on the primary key. Re-add from the machine that removed the key, or any
2018
- * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
2019
- * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
2020
- *
2199
+ * @param label - the user's name for the key, as {@link insertOwnerKey}'s.
2021
2200
  * @returns `true` when this call seated the row, `false` when `key` is already a live owner
2022
- * here (nothing written).
2023
- * @throws when this node holds `key`'s row under a retired stamp (above), or when the
2024
- * schema refuses the signer.
2201
+ * here (nothing written, whatever its label).
2202
+ * @throws when this node holds `key`'s row under a retired stamp
2203
+ * ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
2025
2204
  */
2026
2205
  async insertOwnerKeyVouched(
2027
2206
  key: string,
2028
2207
  ownerKey: string,
2029
- signMessage: (message: Uint8Array) => string
2208
+ signMessage: ControlSign,
2209
+ label?: string | null
2030
2210
  ): Promise<boolean> {
2031
2211
  this.ensureInitialized();
2212
+ const storedLabel = ownerKeyLabel(label);
2032
2213
  return await this.mutateCadrePeer('owner-key-add', async () => {
2033
- // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
2034
- const heldStamp = await this.queryStampId('OwnerKey', key, false);
2035
- if (heldStamp !== null) {
2036
- if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
2037
- log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
2038
- return false;
2039
- }
2040
- throw new Error(
2041
- `Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
2042
- '(OwnerKey rows are not reaped); re-add it from the machine that removed it'
2043
- );
2214
+ if (await this.liveOwnerKeyStamp(key) !== null) {
2215
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
2216
+ return false;
2044
2217
  }
2045
2218
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2046
- const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
2047
- // Bare `exec`: already inside the write lock, which is NOT re-entrant.
2048
- await this.db!.exec(`
2049
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
2050
- with context OwnerKey = ?, Signature = ?
2051
- values (?, ?, ?, ?)
2052
- `, [ownerKey, signature, key, stampId, ownerKey, signature]);
2219
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
2220
+ await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
2053
2221
  log('Owner key %s added (vouched by %s)', key, ownerKey);
2054
2222
  return true;
2055
2223
  });
2056
2224
  }
2057
2225
 
2226
+ /**
2227
+ * The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
2228
+ * insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
2229
+ * the locked body (both reads `retry: false`; see queryStampId's NOTE).
2230
+ *
2231
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
2232
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
2233
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
2234
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
2235
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
2236
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
2237
+ *
2238
+ * @throws when this node holds `key`'s row under a retired stamp (above).
2239
+ */
2240
+ private async liveOwnerKeyStamp(key: string): Promise<string | null> {
2241
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
2242
+ if (heldStamp === null) {
2243
+ return null;
2244
+ }
2245
+ if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
2246
+ throw new Error(
2247
+ `Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
2248
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it'
2249
+ );
2250
+ }
2251
+ return heldStamp;
2252
+ }
2253
+
2254
+ /**
2255
+ * The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
2256
+ * stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
2257
+ * NOT re-entrant.
2258
+ */
2259
+ private async execOwnerKeyVouchedInsert(
2260
+ row: { key: string; label: string | null; stampId: string },
2261
+ ownerKey: string,
2262
+ signature: string
2263
+ ): Promise<void> {
2264
+ await this.db!.exec(`
2265
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
2266
+ with context OwnerKey = ?, Signature = ?
2267
+ values (?, ?, ?, ?, ?)
2268
+ `, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
2269
+ }
2270
+
2058
2271
  /**
2059
2272
  * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
2060
2273
  * stamp, notifying the membership listener once it has committed. The remove branch of
@@ -2075,7 +2288,7 @@ export class ControlDatabase {
2075
2288
  async deleteOwnerKey(
2076
2289
  key: string,
2077
2290
  ownerKey: string,
2078
- signMessage: (message: Uint8Array) => string
2291
+ signMessage: ControlSign
2079
2292
  ): Promise<boolean> {
2080
2293
  if (key === ownerKey) {
2081
2294
  throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
@@ -2097,14 +2310,14 @@ export class ControlDatabase {
2097
2310
  /**
2098
2311
  * Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
2099
2312
  * current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
2100
- * of the same key under a fresh stamp, signed by `ownerKey` ({@link insertOwnerKeyVouched}'s
2101
- * body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
2313
+ * of the same key and label under a fresh stamp, signed by `ownerKey`
2314
+ * ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
2102
2315
  * this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
2103
2316
  * under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
2104
- * {@link removeMachine}: the owners a machine being removed had added keep their anchor
2105
- * place everywhere once their row stands on a remaining owner's signature.
2317
+ * `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
2318
+ * everywhere once their row stands on a remaining owner's signature.
2106
2319
  *
2107
- * Through {@link mutateCadrePeer}, the membership hub. The stamp read runs inside the locked
2320
+ * Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
2108
2321
  * body; a row whose stamp is already retired (held without its delete) is reported as absent
2109
2322
  * rather than tombstoned twice.
2110
2323
  *
@@ -2114,70 +2327,59 @@ export class ControlDatabase {
2114
2327
  async revouchOwnerKey(
2115
2328
  key: string,
2116
2329
  ownerKey: string,
2117
- signMessage: (message: Uint8Array) => string
2330
+ signMessage: ControlSign
2118
2331
  ): Promise<boolean> {
2119
2332
  if (key === ownerKey) {
2120
2333
  throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
2121
2334
  }
2122
2335
  return await this.mutateCadrePeer('owner-key-revouch', async () => {
2123
- // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
2124
- const stampId = await this.queryStampId('OwnerKey', key, false);
2125
- if (stampId === null || (await this.queryRevokedStamps('OwnerKey', false)).has(stampId)) {
2336
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2337
+ const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
2338
+ if (live === undefined) {
2126
2339
  log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
2127
2340
  return false;
2128
2341
  }
2129
- const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
2130
- const freshStampId = generateStampId(this.config.libp2pNode.peerId.toString());
2131
- const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, freshStampId));
2342
+ const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
2343
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2344
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
2132
2345
  await this.inTransaction('revouch OwnerKey', async () => {
2133
2346
  await this.execGuardedRemoval(removal, ownerKey);
2134
- await this.db!.exec(`
2135
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
2136
- with context OwnerKey = ?, Signature = ?
2137
- values (?, ?, ?, ?)
2138
- `, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
2347
+ await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
2139
2348
  });
2140
- log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
2349
+ log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
2141
2350
  this.notifyGuardedDelete(removal.ref);
2142
2351
  return true;
2143
2352
  });
2144
2353
  }
2145
2354
 
2146
2355
  /**
2147
- * Remove one machine completely, in ONE transaction: its `CadrePeer` row, its `OwnerKey`
2148
- * row when `target.ownerKey` names one, its `DeviceToken` row when it has one, each with
2149
- * the `Revocation` tombstone retiring its stamp ({@link deleteGuardedRow}'s statement pair,
2150
- * composed bare as {@link deleteStrandAndPartyKey} composes it), plus a withdrawal tombstone
2151
- * for every `CadreInvite` in `target.inviteKeys` that is still live here
2152
- * ({@link withdrawCadreInvite}'s body). One transaction so no reader sees the machine half
2153
- * removed: a row gone but its push token resolvable, or its owner key gone but an invitation
2154
- * it could redeem again still open.
2356
+ * Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
2357
+ * `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
2358
+ * ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
2359
+ * composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
2360
+ * that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
2361
+ * sees the machine half removed: a row gone but its push token resolvable, or an invitation
2362
+ * it could redeem again still open. Owner keys are never touched: they are the person's, and
2363
+ * removing one is {@link deleteOwnerKey}.
2155
2364
  *
2156
2365
  * Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
2157
2366
  * body, and a row whose stamp is already retired (held without its delete) is skipped rather
2158
- * than tombstoned twice. The by-name owner refusals of {@link deleteOwnerKey} apply to the
2159
- * owner-key arm. Nothing to remove writes nothing and returns all false.
2160
- *
2161
- * The caller decides WHAT to remove, which owner key is the machine's and which invitations
2162
- * it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
2163
- * write an inconsistent set.
2367
+ * than tombstoned twice. Nothing to remove writes nothing and returns all false.
2164
2368
  *
2165
- * @throws when `target.ownerKey` is `ownerKey`, or is the only live owner here.
2369
+ * The caller decides which invitations the machine could use, from its plan
2370
+ * (`CadreNode.planMachineRemoval`).
2166
2371
  */
2167
2372
  async removeMachine(
2168
- target: { peerId: string; ownerKey: string | null; inviteKeys: readonly string[] },
2373
+ target: { peerId: string; inviteKeys: readonly string[] },
2169
2374
  ownerKey: string,
2170
- signMessage: (message: Uint8Array) => string
2171
- ): Promise<MachineRemovalWrites> {
2172
- if (target.ownerKey === ownerKey) {
2173
- throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
2174
- }
2375
+ signMessage: ControlSign
2376
+ ): Promise<MachineRemovalResult> {
2175
2377
  return await this.mutateCadrePeer('machine-remove', async () => {
2176
- const removals = await this.signMachineRemovals(target, signMessage);
2177
- const withdrawals = await this.signCadreInviteWithdrawals(target.inviteKeys, signMessage);
2378
+ const removals = await this.signMachineRemovals(target.peerId, signMessage);
2379
+ const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
2178
2380
  if (removals.length === 0 && withdrawals.length === 0) {
2179
2381
  log('removeMachine: nothing live names %s here (already removed)', target.peerId);
2180
- return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [] };
2382
+ return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
2181
2383
  }
2182
2384
  await this.inTransaction('remove machine', async () => {
2183
2385
  for (const removal of removals) {
@@ -2197,7 +2399,6 @@ export class ControlDatabase {
2197
2399
  }
2198
2400
  return {
2199
2401
  removedPeer: removed.has('CadrePeer'),
2200
- removedOwnerKey: removed.has('OwnerKey'),
2201
2402
  removedDeviceToken: removed.has('DeviceToken'),
2202
2403
  withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
2203
2404
  };
@@ -2206,14 +2407,11 @@ export class ControlDatabase {
2206
2407
 
2207
2408
  /**
2208
2409
  * The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
2209
- * the `CadrePeer` row, the `OwnerKey` row when named, the `DeviceToken` row when present.
2210
- * Inside the locked body (every read `retry: false`); one `Revocation` scan serves the
2211
- * retired-stamp check for all three tables and the last-owner refusal.
2410
+ * the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
2411
+ * (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
2412
+ * tables.
2212
2413
  */
2213
- private async signMachineRemovals(
2214
- target: { peerId: string; ownerKey: string | null },
2215
- signMessage: (message: Uint8Array) => string
2216
- ): Promise<GuardedRemoval[]> {
2414
+ private async signMachineRemovals(peerId: string, signMessage: ControlSign): Promise<GuardedRemoval[]> {
2217
2415
  const retiredByTable = new Map<RevocableTable, Set<string>>();
2218
2416
  for (const tombstone of await this.queryRevocations(false)) {
2219
2417
  const stamps = retiredByTable.get(tombstone.tableName) ?? new Set<string>();
@@ -2226,24 +2424,12 @@ export class ControlDatabase {
2226
2424
  return stampId !== null && !retired(table).has(stampId) ? stampId : null;
2227
2425
  };
2228
2426
  const removals: GuardedRemoval[] = [];
2229
- const peerStamp = await liveStamp('CadrePeer', target.peerId);
2230
- if (peerStamp !== null) {
2231
- removals.push(signGuardedRemoval(this.config.partyId, 'CadrePeer', target.peerId, peerStamp, signMessage));
2232
- }
2233
- if (target.ownerKey !== null) {
2234
- const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
2235
- if (ownerStamp !== null) {
2236
- const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
2237
- if (owners.length <= 1) {
2238
- throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
2239
- }
2240
- removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
2427
+ for (const table of ['CadrePeer', 'DeviceToken'] as const) {
2428
+ const stampId = await liveStamp(table, peerId);
2429
+ if (stampId !== null) {
2430
+ removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
2241
2431
  }
2242
2432
  }
2243
- const tokenStamp = await liveStamp('DeviceToken', target.peerId);
2244
- if (tokenStamp !== null) {
2245
- removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
2246
- }
2247
2433
  return removals;
2248
2434
  }
2249
2435
 
@@ -2270,7 +2456,7 @@ export class ControlDatabase {
2270
2456
  strandId: string,
2271
2457
  type: 'o' | 'c',
2272
2458
  ownerKey: string,
2273
- signMessage: (message: Uint8Array) => string,
2459
+ signMessage: ControlSign,
2274
2460
  memberPrivateKey?: string
2275
2461
  ): Promise<void> {
2276
2462
  this.ensureInitialized();
@@ -2281,19 +2467,19 @@ export class ControlDatabase {
2281
2467
  const stampId = generateStampId(peerId);
2282
2468
 
2283
2469
  // Field order MUST match the schema's Strand `AuthorizedInsert` verify:
2284
- // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
2470
+ // Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
2285
2471
  // deliberately NOT in the digest — the schema binds it by equality to the
2286
2472
  // verified context.OwnerKey instead (see the constraint's comment).
2287
- const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
2288
- const signature = signMessage(message);
2473
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
2474
+ const signature = await signMessage(message);
2289
2475
 
2290
2476
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
2291
2477
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
2292
2478
  await this.execWrite(`
2293
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
2479
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2294
2480
  with context OwnerKey = ?, Signature = ?
2295
- values (?, ?, ?, ?, ?)
2296
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
2481
+ values (?, ?, ?, ?, ?, ?)
2482
+ `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId], 'strand-insert');
2297
2483
 
2298
2484
  log('Strand inserted: %s', strandId);
2299
2485
  }
@@ -2328,7 +2514,7 @@ export class ControlDatabase {
2328
2514
  deleteStrand(
2329
2515
  strandId: string,
2330
2516
  ownerKey: string,
2331
- signMessage: (message: Uint8Array) => string
2517
+ signMessage: ControlSign
2332
2518
  ): Promise<boolean> {
2333
2519
  return this.lockedWithRetry(() => this.deleteStrandAndPartyKey(strandId, ownerKey, signMessage), {}, 'strand-delete');
2334
2520
  }
@@ -2348,7 +2534,7 @@ export class ControlDatabase {
2348
2534
  private async deleteStrandAndPartyKey(
2349
2535
  strandId: string,
2350
2536
  ownerKey: string,
2351
- signMessage: (message: Uint8Array) => string
2537
+ signMessage: ControlSign
2352
2538
  ): Promise<boolean> {
2353
2539
  this.ensureInitialized();
2354
2540
  // retry: false — these reads run inside the locked write body (see queryStampId's NOTE).
@@ -2359,10 +2545,10 @@ export class ControlDatabase {
2359
2545
  }
2360
2546
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
2361
2547
 
2362
- const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
2548
+ const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
2363
2549
  const partyKeyRemoval = partyKeyStamp === null
2364
2550
  ? null
2365
- : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
2551
+ : await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
2366
2552
 
2367
2553
  await this.inTransaction('delete Strand', async () => {
2368
2554
  await this.execGuardedRemoval(strandRemoval, ownerKey);
@@ -2400,7 +2586,7 @@ export class ControlDatabase {
2400
2586
  strandId: string,
2401
2587
  privateKey: string,
2402
2588
  ownerKey: string,
2403
- signMessage: (message: Uint8Array) => string
2589
+ signMessage: ControlSign
2404
2590
  ): Promise<void> {
2405
2591
  this.ensureInitialized();
2406
2592
  log('Inserting strand party key for strand: %s', strandId);
@@ -2411,7 +2597,7 @@ export class ControlDatabase {
2411
2597
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
2412
2598
  // Id, PrivateKey, StampId.
2413
2599
  const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
2414
- const signature = signMessage(message);
2600
+ const signature = await signMessage(message);
2415
2601
 
2416
2602
  await this.execWrite(`
2417
2603
  insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
@@ -2437,7 +2623,7 @@ export class ControlDatabase {
2437
2623
  deleteStrandPartyKey(
2438
2624
  strandId: string,
2439
2625
  ownerKey: string,
2440
- signMessage: (message: Uint8Array) => string
2626
+ signMessage: ControlSign
2441
2627
  ): Promise<boolean> {
2442
2628
  return this.lockedWithRetry(() => this.deleteGuardedRow('StrandPartyKey', strandId, ownerKey, signMessage), {}, 'strand-party-key-delete');
2443
2629
  }
@@ -2457,7 +2643,7 @@ export class ControlDatabase {
2457
2643
  async insertJoinedStrand(
2458
2644
  row: Pick<StrandRow, 'Id' | 'Type' | 'MemberPrivateKey'>,
2459
2645
  ownerKey: string,
2460
- signMessage: (message: Uint8Array) => string
2646
+ signMessage: ControlSign
2461
2647
  ): Promise<void> {
2462
2648
  this.ensureInitialized();
2463
2649
  log('Inserting joined strand: %s (type: %s)', row.Id, row.Type);
@@ -2467,7 +2653,7 @@ export class ControlDatabase {
2467
2653
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
2468
2654
  // Id, Type, MemberPrivateKey ('' when null), StampId.
2469
2655
  const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
2470
- const signature = signMessage(message);
2656
+ const signature = await signMessage(message);
2471
2657
 
2472
2658
  await this.execWrite(`
2473
2659
  insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
@@ -2487,7 +2673,7 @@ export class ControlDatabase {
2487
2673
  deleteJoinedStrand(
2488
2674
  strandId: string,
2489
2675
  ownerKey: string,
2490
- signMessage: (message: Uint8Array) => string
2676
+ signMessage: ControlSign
2491
2677
  ): Promise<boolean> {
2492
2678
  return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
2493
2679
  }
@@ -2502,10 +2688,10 @@ export class ControlDatabase {
2502
2688
  async insertJoinRequest(
2503
2689
  request: JoinRequestFields,
2504
2690
  ownerKey: string,
2505
- signMessage: (message: Uint8Array) => string
2691
+ signMessage: ControlSign
2506
2692
  ): Promise<PendingJoin> {
2507
2693
  this.ensureInitialized();
2508
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2694
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2509
2695
  await this.execWrite(insert.sql, insert.params, 'join-request-insert');
2510
2696
  log('Join request inserted: %s', request.Id);
2511
2697
  return { ...written, outcome: null };
@@ -2528,10 +2714,10 @@ export class ControlDatabase {
2528
2714
  expected: PendingJoin,
2529
2715
  outcome: JoinOutcome,
2530
2716
  ownerKey: string,
2531
- signMessage: (message: Uint8Array) => string
2717
+ signMessage: ControlSign
2532
2718
  ): Promise<PendingJoin> {
2533
2719
  this.ensureInitialized();
2534
- const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2720
+ const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2535
2721
  return this.lockedWithRetry(async () => {
2536
2722
  const live = await this.readPendingJoin(expected.Id, false);
2537
2723
  if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
@@ -2566,13 +2752,13 @@ export class ControlDatabase {
2566
2752
  expectedStampId: string,
2567
2753
  next: PendingJoinFields,
2568
2754
  ownerKey: string,
2569
- signMessage: (message: Uint8Array) => string
2755
+ signMessage: ControlSign
2570
2756
  ): Promise<PendingJoin> {
2571
2757
  this.ensureInitialized();
2572
2758
  const { outcome, ...request } = next;
2573
- const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2574
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2575
- const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2759
+ const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2760
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2761
+ const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2576
2762
 
2577
2763
  return this.lockedWithRetry(async () => {
2578
2764
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
@@ -2603,7 +2789,7 @@ export class ControlDatabase {
2603
2789
  deletePendingJoin(
2604
2790
  id: string,
2605
2791
  ownerKey: string,
2606
- signMessage: (message: Uint8Array) => string
2792
+ signMessage: ControlSign
2607
2793
  ): Promise<boolean> {
2608
2794
  return this.lockedWithRetry(
2609
2795
  () => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)),
@@ -2614,13 +2800,13 @@ export class ControlDatabase {
2614
2800
  * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
2615
2801
  * verifies, returning the row as it will be written and its insert.
2616
2802
  */
2617
- private signJoinRequestInsert(
2803
+ private async signJoinRequestInsert(
2618
2804
  request: JoinRequestFields,
2619
2805
  ownerKey: string,
2620
- signMessage: (message: Uint8Array) => string
2621
- ): { written: JoinRequestRow; insert: SignedStatement } {
2806
+ signMessage: ControlSign
2807
+ ): Promise<{ written: JoinRequestRow; insert: SignedStatement }> {
2622
2808
  const written: JoinRequestRow = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
2623
- const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2809
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2624
2810
  const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
2625
2811
  return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
2626
2812
  }
@@ -2660,7 +2846,7 @@ export class ControlDatabase {
2660
2846
  async insertValidationKey(
2661
2847
  key: string,
2662
2848
  ownerKey: string,
2663
- signMessage: (message: Uint8Array) => string
2849
+ signMessage: ControlSign
2664
2850
  ): Promise<void> {
2665
2851
  this.ensureInitialized();
2666
2852
  log('Inserting validation key: %s', key);
@@ -2670,7 +2856,7 @@ export class ControlDatabase {
2670
2856
 
2671
2857
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
2672
2858
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
2673
- const signature = signMessage(message);
2859
+ const signature = await signMessage(message);
2674
2860
 
2675
2861
  await this.execWrite(`
2676
2862
  insert into CadreControl.ValidationKey (Key, StampId)
@@ -2700,7 +2886,7 @@ export class ControlDatabase {
2700
2886
  deleteValidationKey(
2701
2887
  key: string,
2702
2888
  ownerKey: string,
2703
- signMessage: (message: Uint8Array) => string
2889
+ signMessage: ControlSign
2704
2890
  ): Promise<boolean> {
2705
2891
  return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage), {}, 'validation-key-delete');
2706
2892
  }
@@ -2752,13 +2938,13 @@ export class ControlDatabase {
2752
2938
  profile: NodeProfile | null;
2753
2939
  },
2754
2940
  ownerKey: string,
2755
- signMessage: (message: Uint8Array) => string
2941
+ signMessage: ControlSign
2756
2942
  ): Promise<boolean> {
2757
2943
  this.ensureInitialized();
2758
2944
  // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
2759
2945
  // row it vouches.
2760
2946
  const stampId = generateStampId(row.peerId);
2761
- const signature = signMessage(
2947
+ const signature = await signMessage(
2762
2948
  buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId])
2763
2949
  );
2764
2950
  return await this.mutateCadrePeer('peer-insert', async () => {
@@ -2822,13 +3008,13 @@ export class ControlDatabase {
2822
3008
  *
2823
3009
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
2824
3010
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
2825
- * reader's node-local anchor. Benign today because the only caller — the write-while-alone
2826
- * drain — re-touches solely rows this node itself authored (`pendingPeerWrites`), so the
2827
- * voucher is rewritten to the key that already signed it. If a future path ever lets one
2828
- * owner re-touch a row a DIFFERENT owner vouched, the voucher silently flips: readers that
2829
- * anchor the original owner but not this one would drop a legitimate member. Such a path
2830
- * must re-vouch deliberately (or preserve the existing VouchOwner/VouchSig) rather than
2831
- * inherit this rebinding.
3011
+ * reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
3012
+ * re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
3013
+ * rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
3014
+ * key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
3015
+ * flips the voucher silently: readers that anchor the original owner but not this one would
3016
+ * drop a legitimate member. It must re-vouch deliberately (or preserve the existing
3017
+ * VouchOwner/VouchSig) rather than inherit this rebinding.
2832
3018
  *
2833
3019
  * @param peerId - the membership row to re-touch.
2834
3020
  * @param updatedAt - the strictly-increasing freshness stamp to write.
@@ -2840,7 +3026,7 @@ export class ControlDatabase {
2840
3026
  peerId: string,
2841
3027
  updatedAt: number,
2842
3028
  ownerKey: string,
2843
- signMessage: (message: Uint8Array) => string
3029
+ signMessage: ControlSign
2844
3030
  ): Promise<boolean> {
2845
3031
  this.ensureInitialized();
2846
3032
  const stampId = await this.queryStampId('CadrePeer', peerId);
@@ -2848,7 +3034,7 @@ export class ControlDatabase {
2848
3034
  log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
2849
3035
  return false;
2850
3036
  }
2851
- const signature = signMessage(
3037
+ const signature = await signMessage(
2852
3038
  buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId])
2853
3039
  );
2854
3040
  await this.mutateCadrePeer('peer-reauthorize', async () => {
@@ -2886,7 +3072,7 @@ export class ControlDatabase {
2886
3072
  deleteCadrePeer(
2887
3073
  peerId: string,
2888
3074
  ownerKey: string,
2889
- signMessage: (message: Uint8Array) => string
3075
+ signMessage: ControlSign
2890
3076
  ): Promise<boolean> {
2891
3077
  return this.mutateCadrePeer('peer-remove', () =>
2892
3078
  this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
@@ -2908,7 +3094,7 @@ export class ControlDatabase {
2908
3094
  deleteDeviceToken(
2909
3095
  peerId: string,
2910
3096
  ownerKey: string,
2911
- signMessage: (message: Uint8Array) => string
3097
+ signMessage: ControlSign
2912
3098
  ): Promise<boolean> {
2913
3099
  return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage), {}, 'device-token-delete');
2914
3100
  }
@@ -2944,7 +3130,7 @@ export class ControlDatabase {
2944
3130
  table: RemovableTable,
2945
3131
  keyValue: string,
2946
3132
  ownerKey: string,
2947
- signMessage: (message: Uint8Array) => string,
3133
+ signMessage: ControlSign,
2948
3134
  alongside?: (stampId: string) => Promise<void>
2949
3135
  ): Promise<boolean> {
2950
3136
  this.ensureInitialized();
@@ -2955,7 +3141,7 @@ export class ControlDatabase {
2955
3141
  return false;
2956
3142
  }
2957
3143
 
2958
- const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
3144
+ const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2959
3145
  await this.inTransaction(`delete ${table}`, async () => {
2960
3146
  await alongside?.(stampId);
2961
3147
  await this.execGuardedRemoval(removal, ownerKey);
@@ -2991,8 +3177,8 @@ export class ControlDatabase {
2991
3177
 
2992
3178
  /**
2993
3179
  * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2994
- * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
2995
- * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
3180
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
3181
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
2996
3182
  * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2997
3183
  * lock and, where one is needed, the transaction.
2998
3184
  *
@@ -3243,25 +3429,28 @@ export class ControlDatabase {
3243
3429
  *
3244
3430
  * Returns how many UPDATE statements ran (`rows.length` on success).
3245
3431
  */
3246
- reissueRevocations(
3432
+ async reissueRevocations(
3247
3433
  rows: readonly RevocationRow[],
3248
3434
  reissuedAt: number,
3249
3435
  ownerKey: string,
3250
- signMessage: (message: Uint8Array) => string
3436
+ signMessage: ControlSign
3251
3437
  ): Promise<number> {
3252
3438
  this.ensureInitialized();
3253
3439
  // Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
3254
3440
  // exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
3255
3441
  // rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
3256
3442
  // and every digest field is TEXT on both sides (see buildAuthorizationMessage).
3257
- const signed = rows.map((row) => ({
3258
- row,
3259
- signature: signMessage(buildAuthorizationMessage(
3260
- 'CadreControl.Revocation', 'reissue', this.config.partyId,
3261
- [row.tableName, row.rowKey, row.stampId, String(reissuedAt)]
3262
- )),
3263
- }));
3264
- return this.lockedWithRetry(async () => {
3443
+ const signed: Array<{ row: RevocationRow; signature: string }> = [];
3444
+ for (const row of rows) {
3445
+ signed.push({
3446
+ row,
3447
+ signature: await signMessage(buildAuthorizationMessage(
3448
+ 'CadreControl.Revocation', 'reissue', this.config.partyId,
3449
+ [row.tableName, row.rowKey, row.stampId, String(reissuedAt)]
3450
+ )),
3451
+ });
3452
+ }
3453
+ return await this.lockedWithRetry(async () => {
3265
3454
  // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
3266
3455
  // re-executes the whole body) starts from zero instead of double-counting.
3267
3456
  let executed = 0;
@@ -3326,10 +3515,10 @@ export class ControlDatabase {
3326
3515
  */
3327
3516
  async openRevocationLedger(
3328
3517
  ownerKey: string,
3329
- signMessage: (message: Uint8Array) => string
3518
+ signMessage: ControlSign
3330
3519
  ): Promise<RevocationLedgerOpenResult> {
3331
3520
  this.ensureInitialized();
3332
- const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
3521
+ const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
3333
3522
  try {
3334
3523
  return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
3335
3524
  if (await this.revocationLedgerFiled()) {
@@ -3447,8 +3636,9 @@ export class ControlDatabase {
3447
3636
  * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
3448
3637
  * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
3449
3638
  * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
3450
- * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
3451
- * which seats both. That is what makes the party-membership snapshot refresh automatic
3639
+ * {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
3640
+ * seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
3641
+ * makes the party-membership snapshot refresh automatic
3452
3642
  * rather than a caller obligation. The name predates the `OwnerKey` writers. A
3453
3643
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
3454
3644
  * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
@@ -3561,7 +3751,7 @@ export class ControlDatabase {
3561
3751
  * backoff never stalls the other local writers queued behind it. Safe because every
3562
3752
  * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
3563
3753
  *
3564
- * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
3754
+ * Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
3565
3755
  * `initialized` is set — so it reaches this method directly rather than through
3566
3756
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
3567
3757
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
@@ -3707,7 +3897,7 @@ export class ControlDatabase {
3707
3897
  token: string,
3708
3898
  sAppId: string,
3709
3899
  ownerKey: string,
3710
- signMessage: (message: Uint8Array) => string,
3900
+ signMessage: ControlSign,
3711
3901
  options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
3712
3902
  ): Promise<void> {
3713
3903
  this.ensureInitialized();
@@ -3734,7 +3924,7 @@ export class ControlDatabase {
3734
3924
  const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
3735
3925
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
3736
3926
  ]);
3737
- const signature = signMessage(message);
3927
+ const signature = await signMessage(message);
3738
3928
 
3739
3929
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
3740
3930
  // signed source-of-truth and the stored value are produced once. StampId is a real,
@@ -3842,13 +4032,13 @@ export class ControlDatabase {
3842
4032
  await this.inTransaction('redemption', async () => {
3843
4033
  // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
3844
4034
  // still carrying a fresh unique StampId for the anti-replay column.
3845
- // Hard-coded open + keyless + explicit null FounderOwnerKey: the
4035
+ // Hard-coded open + keyless + explicit null founder columns: the
3846
4036
  // consent branch admits no other shape (no signature means no
3847
- // trustworthy founder machine to record — see the schema's NOTE).
4037
+ // trustworthy founder machine to record — see the schema's comment).
3848
4038
  await this.db!.exec(`
3849
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
4039
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
3850
4040
  with context OwnerKey = null, Signature = null
3851
- values (?, 'o', null, ?, null)
4041
+ values (?, 'o', null, ?, null, null)
3852
4042
  `, [strandId, strandStampId]);
3853
4043
 
3854
4044
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
@@ -4180,84 +4370,121 @@ export class ControlDatabase {
4180
4370
  * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
4181
4371
  * same shape).
4182
4372
  *
4183
- * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
4184
- * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
4185
- * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
4186
- * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
4187
- * @param invite.totalUses - how many redemptions it allows; omit for unlimited
4188
4373
  * @returns the row as stored, `expiresAt` in its canonical form
4189
4374
  */
4190
- async insertCadreInvite(
4191
- invite: {
4192
- key: string;
4193
- peerId?: string | null;
4194
- grantsOwner: boolean;
4195
- expiresAtMs?: number | null;
4196
- totalUses?: number | null;
4197
- },
4198
- ownerKey: string,
4199
- signMessage: (message: Uint8Array) => string
4200
- ): Promise<CadreInviteRow> {
4375
+ async insertCadreInvite(invite: CadreInviteFields, ownerKey: string, signMessage: ControlSign): Promise<CadreInviteRow> {
4376
+ const row = await this.signCadreInvite(invite, ownerKey, signMessage);
4377
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
4378
+ log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
4379
+ return row;
4380
+ }
4381
+
4382
+ /**
4383
+ * Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
4384
+ * {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
4385
+ * invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
4386
+ * issuer would refuse the local insert and the row is seated at a member from the bundle
4387
+ * instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
4388
+ * store, so the row reads the same wherever it is seated.
4389
+ */
4390
+ async signCadreInvite(invite: CadreInviteFields, ownerKey: string, signMessage: ControlSign): Promise<CadreInviteRow> {
4201
4391
  this.ensureInitialized();
4202
4392
  const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
4203
4393
  const signed: CadreInviteSignedFields = {
4204
4394
  key,
4205
4395
  peerId: invite.peerId ?? null,
4206
- grantsOwner: invite.grantsOwner,
4207
4396
  expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db!, invite.expiresAtMs),
4208
4397
  totalUses: invite.totalUses ?? null,
4209
4398
  stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
4210
4399
  };
4211
- const row: CadreInviteRow = {
4400
+ return {
4212
4401
  ...signed,
4213
4402
  issuerKey: ownerKey,
4214
- issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
4403
+ issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
4215
4404
  };
4216
- await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
4217
- log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
4218
- return row;
4219
4405
  }
4220
4406
 
4221
4407
  /**
4222
4408
  * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
4223
4409
  * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
4224
- * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
4410
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
4411
+ * and so is one whose insert the cohort refuses because a member there holds it.
4225
4412
  *
4226
4413
  * @returns `true` when this call inserted the row, `false` when it was already present.
4227
4414
  * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
4228
4415
  * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
4229
- * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4416
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
4417
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4230
4418
  */
4231
4419
  async seatCadreInvite(row: CadreInviteRow): Promise<boolean> {
4420
+ return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row),
4421
+ cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
4422
+ }
4423
+
4424
+ /**
4425
+ * Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
4426
+ * {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
4427
+ * row's own stored issuer pair.
4428
+ *
4429
+ * @returns `true` when this call inserted the row, `false` when it was already present —
4430
+ * here, or at the cohort member that refused the insert for holding it
4431
+ * ({@link adoptCohortHeldRow}).
4432
+ * @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
4433
+ * {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
4434
+ * read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4435
+ */
4436
+ private async seatIssuedRow(table: IssuedTable, key: string, exec: () => Promise<void>, refuse: (cause: unknown) => Error): Promise<boolean> {
4232
4437
  this.ensureInitialized();
4233
4438
  try {
4234
4439
  return await this.lockedWithRetry(async () => {
4235
4440
  // retry: false — inside the locked write body (see queryStampId's NOTE).
4236
- if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
4237
- log('CadreInvite %s already seated; seat skipped', row.key);
4441
+ if (await this.queryStampId(table, key, false) !== null) {
4442
+ log('%s %s already seated; seat skipped', table, key);
4238
4443
  return false;
4239
4444
  }
4240
- await this.execCadreInviteInsert(row);
4241
- log('Cadre invitation seated from its bundle: %s', row.key);
4445
+ await exec();
4446
+ log('%s seated from its bundle: %s', table, key);
4242
4447
  return true;
4243
- }, {}, 'cadre-invite-seat');
4448
+ }, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
4244
4449
  } catch (error) {
4245
- if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
4246
- throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
4450
+ if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
4451
+ throw refuse(error);
4452
+ }
4453
+ if (isIssuedRowKeyConflict(error, table)) {
4454
+ return await this.adoptCohortHeldRow(table, key, error);
4247
4455
  }
4248
4456
  throw error;
4249
4457
  }
4250
4458
  }
4251
4459
 
4460
+ /**
4461
+ * The seat's insert was refused because a cohort member already holds a row under `key`, which
4462
+ * this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
4463
+ * and every column is under the issuer's signature, so the held row is this one: it counts as
4464
+ * seated once a refreshing read (unlocked, so retried as any read) finds it here.
4465
+ *
4466
+ * @throws {IssuedRowNotHereYetError} when the read still does not find it.
4467
+ */
4468
+ private async adoptCohortHeldRow(table: IssuedTable, key: string, conflict: unknown): Promise<false> {
4469
+ // NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
4470
+ // which does not refresh from the cohort, so it can miss the row and refuse a retryable
4471
+ // `conflict`; if that refusal shows up in practice, wait for the running body before reading.
4472
+ if (await this.queryStampId(table, key) === null) {
4473
+ throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
4474
+ }
4475
+ log('%s %s is held by the cohort; seat skipped', table, key);
4476
+ return false;
4477
+ }
4478
+
4252
4479
  /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
4253
4480
  private async execCadreInviteInsert(row: CadreInviteRow): Promise<void> {
4254
4481
  await this.db!.exec(`
4255
- insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4482
+ insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4256
4483
  with context OwnerKey = ?, Signature = ?
4257
- values (?, ?, ?, ?, ?, ?, ?, ?)
4484
+ values (?, ?, ?, ?, ?, ?, ?)
4258
4485
  `, [
4259
4486
  row.issuerKey, row.issuerSig,
4260
- row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
4487
+ row.key, row.peerId, row.expiresAt, row.totalUses,
4261
4488
  row.issuerKey, row.issuerSig, row.stampId,
4262
4489
  ]);
4263
4490
  }
@@ -4299,7 +4526,7 @@ export class ControlDatabase {
4299
4526
  async queryCadreInviteUsages(retry = true): Promise<CadreInviteUsageRow[]> {
4300
4527
  this.ensureInitialized();
4301
4528
  const rows = await this.readRows(
4302
- 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
4529
+ 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
4303
4530
  undefined, 'cadre-invite-usages', retry
4304
4531
  );
4305
4532
  return rows.map(cadreInviteUsageRowOf);
@@ -4313,9 +4540,13 @@ export class ControlDatabase {
4313
4540
  * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
4314
4541
  */
4315
4542
  async countCadreInviteUsage(key: string, retry = true): Promise<number> {
4543
+ return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
4544
+ }
4545
+
4546
+ /** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
4547
+ private async countUsageRows(sql: string, key: string, label: string, retry: boolean): Promise<number> {
4316
4548
  this.ensureInitialized();
4317
- const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
4318
- for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
4549
+ for (const row of await this.readRows(sql, [key], label, retry)) {
4319
4550
  return (row.Count as number) ?? 0;
4320
4551
  }
4321
4552
  return 0;
@@ -4351,7 +4582,7 @@ export class ControlDatabase {
4351
4582
  undefined,
4352
4583
  'live-cadre-invites'
4353
4584
  )) {
4354
- const open = cadreInviteStillOpen(
4585
+ const open = issuedRowStillOpen(
4355
4586
  { stampId: row.StampId as string, issuerKey: row.IssuerKey as string, expiresAt: (row.ExpiresAt as string | null) ?? null },
4356
4587
  withdrawn, owners, nowMs
4357
4588
  );
@@ -4391,14 +4622,27 @@ export class ControlDatabase {
4391
4622
  async isCadreInviteLive(invite: CadreInviteRow, nowMs: number = Date.now()): Promise<boolean> {
4392
4623
  this.ensureInitialized();
4393
4624
  const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
4625
+ return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
4626
+ }
4627
+
4628
+ /**
4629
+ * The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
4630
+ * or a verified bundle copy; null for neither) still open and under its use cap?
4631
+ */
4632
+ private async issuedRowLive(
4633
+ table: IssuedTable,
4634
+ row: { key: string; stampId: string; issuerKey: string; expiresAt: string | null; totalUses: number | null } | null,
4635
+ nowMs: number,
4636
+ countUsage: (key: string) => Promise<number>
4637
+ ): Promise<boolean> {
4394
4638
  if (row === null) {
4395
4639
  return false;
4396
4640
  }
4397
- const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
4398
- if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
4641
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
4642
+ if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
4399
4643
  return false;
4400
4644
  }
4401
- return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
4645
+ return row.totalUses === null || await countUsage(row.key) < row.totalUses;
4402
4646
  }
4403
4647
 
4404
4648
  /**
@@ -4409,22 +4653,34 @@ export class ControlDatabase {
4409
4653
  * occasional read, not a gate's.
4410
4654
  */
4411
4655
  async listCadreInviteStatuses(nowMs: number = Date.now()): Promise<CadreInviteStatus[]> {
4656
+ const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
4657
+ return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
4658
+ }
4659
+
4660
+ /**
4661
+ * The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
4662
+ * `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
4663
+ */
4664
+ private async issuedRowStandings<Row extends { key: string; stampId: string; issuerKey: string; expiresAt: string | null; totalUses: number | null }>(
4665
+ table: IssuedTable,
4666
+ rows: readonly Row[],
4667
+ nowMs: number,
4668
+ countUsage: (key: string) => Promise<number>
4669
+ ): Promise<Array<IssuedRowStanding<Row>>> {
4412
4670
  this.ensureInitialized();
4413
- const [invites, withdrawnStamps, owners] = await Promise.all([
4414
- this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
4415
- ]);
4416
- const statuses: CadreInviteStatus[] = [];
4417
- // NOTE: one retried count read per invitation, withdrawn and expired rows included, since
4418
- // rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
4419
- // usage counts in one statement or skip the count for rows that are no longer open.
4420
- for (const invite of invites) {
4421
- const usesRecorded = await this.countCadreInviteUsage(invite.key);
4422
- const withdrawn = withdrawnStamps.has(invite.stampId);
4423
- const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
4424
- && (invite.totalUses === null || usesRecorded < invite.totalUses);
4425
- statuses.push({ invite, live, withdrawn, usesRecorded });
4671
+ const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
4672
+ const standings: Array<IssuedRowStanding<Row>> = [];
4673
+ // NOTE: one retried count read per row, withdrawn and expired rows included, since rows
4674
+ // are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
4675
+ // counts in one statement or skip the count for rows that are no longer open.
4676
+ for (const row of rows) {
4677
+ const usesRecorded = await countUsage(row.key);
4678
+ const withdrawn = withdrawnStamps.has(row.stampId);
4679
+ const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
4680
+ && (row.totalUses === null || usesRecorded < row.totalUses);
4681
+ standings.push({ row, live, withdrawn, usesRecorded });
4426
4682
  }
4427
- return statuses;
4683
+ return standings;
4428
4684
  }
4429
4685
 
4430
4686
  /**
@@ -4447,66 +4703,75 @@ export class ControlDatabase {
4447
4703
  async withdrawCadreInvite(
4448
4704
  key: string,
4449
4705
  ownerKey: string,
4450
- signMessage: (message: Uint8Array) => string
4706
+ signMessage: ControlSign
4451
4707
  ): Promise<boolean> {
4708
+ return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
4709
+ }
4710
+
4711
+ /**
4712
+ * The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
4713
+ * lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
4714
+ * the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
4715
+ */
4716
+ private async withdrawIssuedRow(table: IssuedTable, key: string, ownerKey: string, signMessage: ControlSign): Promise<boolean> {
4452
4717
  this.ensureInitialized();
4453
4718
  const filed = await this.lockedWithRetry(async (): Promise<RevokedRowRef | null> => {
4454
- const [withdrawal] = await this.signCadreInviteWithdrawals([key], signMessage);
4719
+ const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
4455
4720
  if (withdrawal === undefined) {
4456
4721
  return null;
4457
4722
  }
4458
4723
  await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
4459
4724
  return withdrawal.ref;
4460
- }, {}, 'cadre-invite-withdraw');
4725
+ }, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
4461
4726
  if (filed === null) {
4462
4727
  return false;
4463
4728
  }
4464
- log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
4729
+ log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
4465
4730
  this.notifyGuardedDelete(filed);
4466
4731
  return true;
4467
4732
  }
4468
4733
 
4469
4734
  /**
4470
- * The withdrawal tombstones for the `CadreInvite` rows among `keys` that are held here and
4471
- * not yet withdrawn, signed and ready for {@link execTombstone}: the body
4472
- * {@link withdrawCadreInvite} files one of and {@link removeMachine} files several of in one
4735
+ * The withdrawal tombstones for the `table` rows among `keys` that are held here and not
4736
+ * yet withdrawn, signed and ready for {@link execTombstone}: the body
4737
+ * {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
4473
4738
  * transaction. Inside the locked body (every read `retry: false`); an absent or already
4474
4739
  * withdrawn key is logged and left out.
4475
4740
  */
4476
- private async signCadreInviteWithdrawals(
4741
+ private async signWithdrawals(
4742
+ table: IssuedTable,
4477
4743
  keys: readonly string[],
4478
- signMessage: (message: Uint8Array) => string
4744
+ signMessage: ControlSign
4479
4745
  ): Promise<Array<{ ref: RevokedRowRef; signature: string }>> {
4480
4746
  if (keys.length === 0) {
4481
4747
  return [];
4482
4748
  }
4483
- const withdrawn = await this.queryRevokedStamps('CadreInvite', false);
4749
+ const withdrawn = await this.queryRevokedStamps(table, false);
4484
4750
  const withdrawals: Array<{ ref: RevokedRowRef; signature: string }> = [];
4485
4751
  for (const key of keys) {
4486
- const stampId = await this.queryStampId('CadreInvite', key, false);
4752
+ const stampId = await this.queryStampId(table, key, false);
4487
4753
  if (stampId === null) {
4488
- log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
4754
+ log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
4489
4755
  continue;
4490
4756
  }
4491
4757
  if (withdrawn.has(stampId)) {
4492
- log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
4758
+ log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
4493
4759
  continue;
4494
4760
  }
4495
- const ref: RevokedRowRef = { tableName: 'CadreInvite', rowKey: key, stampId };
4496
- withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
4761
+ const ref: RevokedRowRef = { tableName: table, rowKey: key, stampId };
4762
+ withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
4497
4763
  }
4498
4764
  return withdrawals;
4499
4765
  }
4500
4766
 
4501
4767
  /**
4502
- * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
4503
- * when the invitation grants ownership, as an owner — with NO owner signature. One
4504
- * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
4505
- * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
4506
- * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
4507
- * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
4508
- * redemption and none can be held in reserve. The invitation must already be committed
4509
- * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
4768
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
4769
+ * with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
4770
+ * One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
4771
+ * the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
4772
+ * reads the peer row back, so the two are mutually bound to this exact redemption and
4773
+ * neither can be held in reserve. The invitation must already be committed
4774
+ * ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
4510
4775
  *
4511
4776
  * The two signatures are the holder's and the device's, minted on the device over
4512
4777
  * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
@@ -4515,18 +4780,17 @@ export class ControlDatabase {
4515
4780
  * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
4516
4781
  *
4517
4782
  * Idempotent for a retry after a dropped reply: when the device's row is already present
4518
- * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
4519
- * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
4520
- * owner-granting invitation is completed: the usage row then names the existing peer stamp.
4521
- * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
4522
- * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
4783
+ * nothing is written and no seat is spent — `alreadyMember: true`. The seat check
4784
+ * ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
4785
+ * redemption is answered `alreadyMember` rather than refused as exhausted.
4523
4786
  *
4524
4787
  * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
4525
4788
  * every read passes `retry: false` and every stamp is minted per attempt, so a
4526
4789
  * {@link lockedWithRetry} re-run starts clean.
4527
4790
  *
4528
4791
  * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
4529
- * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
4792
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
4793
+ * {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
4530
4794
  */
4531
4795
  async redeemCadreInvite(params: {
4532
4796
  inviteKey: string;
@@ -4552,64 +4816,39 @@ export class ControlDatabase {
4552
4816
  return this.mutateCadrePeer('cadre-invite-redeem', async () => {
4553
4817
  const invite = await this.queryCadreInvite(inviteKey, false);
4554
4818
  if (invite === null) {
4555
- throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
4819
+ throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
4556
4820
  }
4557
- const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
4558
- if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
4821
+ // A raw stamp read (queryStampId), so a physically present row counts; inside the locked
4822
+ // body, so never retried.
4823
+ const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
4824
+ if (seatedPeerStampId !== null) {
4559
4825
  log('redeemCadreInvite: %s already admitted; nothing written', peerId);
4560
- return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
4826
+ return { alreadyMember: true, peerStampId: seatedPeerStampId };
4561
4827
  }
4562
4828
  await this.assertCadreInviteSeatRemains(invite);
4563
4829
 
4564
- // Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
4565
- // the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
4566
- // attempt rather than re-presenting a stamp the usage row may already name.
4567
- const peerStampId = seated.peerStampId ?? generateStampId(peerId);
4568
- const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
4830
+ // The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
4831
+ // names the row it seats. Minted inside the body, so a re-run mints afresh over a
4832
+ // rolled-back attempt rather than re-presenting a stamp the usage row may already name.
4833
+ const peerStampId = generateStampId(peerId);
4569
4834
  const nowCanonical = await canonicalDatetime(this.db!, nowMs);
4570
4835
  await this.inTransaction('cadre-invite-redeem', async () => {
4571
- if (seated.peerStampId === null) {
4572
- await this.db!.exec(`
4573
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
4574
- with context OwnerKey = null, Signature = null
4575
- values (?, ?, ?, ?, null, ?, ?, null, ?)
4576
- `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
4577
- }
4578
- if (ownerStampId !== null && seated.ownerStampId === null) {
4579
- await this.db!.exec(`
4580
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
4581
- with context OwnerKey = null, Signature = null
4582
- values (?, ?, ?, null, ?)
4583
- `, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
4584
- }
4585
4836
  await this.db!.exec(`
4586
- insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
4837
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
4838
+ with context OwnerKey = null, Signature = null
4839
+ values (?, ?, ?, ?, null, ?, ?, null, ?)
4840
+ `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
4841
+ await this.db!.exec(`
4842
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
4587
4843
  with context Now = ?
4588
- values (?, ?, ?, ?, ?, ?, ?, ?)
4589
- `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
4844
+ values (?, ?, ?, ?, ?, ?, ?)
4845
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
4590
4846
  });
4591
- log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
4592
- return { alreadyMember: false, peerStampId, ownerStampId };
4847
+ log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
4848
+ return { alreadyMember: false, peerStampId };
4593
4849
  });
4594
4850
  }
4595
4851
 
4596
- /**
4597
- * The rows a redemption would seat that are already present: the device's `CadrePeer`
4598
- * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
4599
- * reads ({@link queryStampId}), so a physically present row counts; inside the locked
4600
- * body, so never retried.
4601
- */
4602
- private async readSeatedAdmission(
4603
- peerId: string,
4604
- peerKey: string,
4605
- grantsOwner: boolean
4606
- ): Promise<{ peerStampId: string | null; ownerStampId: string | null }> {
4607
- return {
4608
- peerStampId: await this.queryStampId('CadrePeer', peerId, false),
4609
- ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
4610
- };
4611
- }
4612
-
4613
4852
  /**
4614
4853
  * Refuse a redemption that would consume a seat the cadre invitation does not have, by
4615
4854
  * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
@@ -4617,14 +4856,259 @@ export class ControlDatabase {
4617
4856
  * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
4618
4857
  * committed count inside the locked body, so on a same-node race the loser is refused here.
4619
4858
  */
4620
- private async assertCadreInviteSeatRemains(invite: CadreInviteRow): Promise<void> {
4621
- if (invite.totalUses === null) {
4859
+ private assertCadreInviteSeatRemains(invite: CadreInviteRow): Promise<void> {
4860
+ return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
4861
+ }
4862
+
4863
+ /** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
4864
+ private async assertIssuedSeatRemains(row: { key: string; totalUses: number | null }, countUsage: (key: string) => Promise<number>): Promise<void> {
4865
+ if (row.totalUses === null) {
4622
4866
  return;
4623
4867
  }
4624
- const used = await this.countCadreInviteUsage(invite.key, false);
4625
- if (used >= invite.totalUses) {
4626
- throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
4868
+ const used = await countUsage(row.key);
4869
+ if (used >= row.totalUses) {
4870
+ throw new InvitationExhaustedError(row.key, used, row.totalUses);
4871
+ }
4872
+ }
4873
+
4874
+ // ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
4875
+
4876
+ /**
4877
+ * Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
4878
+ * grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
4879
+ * to seat an owner key the holder generates or already holds. No machine is admitted and no
4880
+ * peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
4881
+ * row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
4882
+ * the returned row is what the grant bundle carries, re-inserted verbatim by
4883
+ * {@link seatOwnerGrant} on a member that has not received it by replication.
4884
+ *
4885
+ * @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
4886
+ * caller minted the pair and hands the private half to the holder out of band
4887
+ * @param grant.label - the label every key this grant seats will carry
4888
+ * ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
4889
+ * issuer's choice, which the holder cannot change
4890
+ * @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
4891
+ * never. The short default lifetime is the node API's to apply, not this writer's.
4892
+ * @param grant.totalUses - how many redemptions it allows; omit for unlimited
4893
+ * @returns the row as stored, `expiresAt` in its canonical form
4894
+ */
4895
+ async insertOwnerGrant(
4896
+ grant: { key: string; label?: string | null; expiresAtMs?: number | null; totalUses?: number | null },
4897
+ ownerKey: string,
4898
+ signMessage: ControlSign
4899
+ ): Promise<OwnerGrantRow> {
4900
+ this.ensureInitialized();
4901
+ const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
4902
+ const signed: OwnerGrantSignedFields = {
4903
+ key,
4904
+ label: ownerKeyLabel(grant.label),
4905
+ expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db!, grant.expiresAtMs),
4906
+ totalUses: grant.totalUses ?? null,
4907
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
4908
+ };
4909
+ const row: OwnerGrantRow = {
4910
+ ...signed,
4911
+ issuerKey: ownerKey,
4912
+ issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
4913
+ };
4914
+ await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
4915
+ log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
4916
+ return row;
4917
+ }
4918
+
4919
+ /**
4920
+ * Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
4921
+ * `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
4922
+ *
4923
+ * @returns `true` when this call inserted the row, `false` when it was already present.
4924
+ * @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
4925
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
4926
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
4927
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4928
+ */
4929
+ async seatOwnerGrant(row: OwnerGrantRow): Promise<boolean> {
4930
+ return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row),
4931
+ cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
4932
+ }
4933
+
4934
+ /** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
4935
+ private async execOwnerGrantInsert(row: OwnerGrantRow): Promise<void> {
4936
+ await this.db!.exec(`
4937
+ insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4938
+ with context OwnerKey = ?, Signature = ?
4939
+ values (?, ?, ?, ?, ?, ?, ?)
4940
+ `, [
4941
+ row.issuerKey, row.issuerSig,
4942
+ row.key, row.label, row.expiresAt, row.totalUses,
4943
+ row.issuerKey, row.issuerSig, row.stampId,
4944
+ ]);
4945
+ }
4946
+
4947
+ /**
4948
+ * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
4949
+ * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
4950
+ * away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
4951
+ */
4952
+ async queryOwnerGrant(key: string, retry = true): Promise<OwnerGrantRow | null> {
4953
+ this.ensureInitialized();
4954
+ for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
4955
+ return ownerGrantRowOf(row);
4627
4956
  }
4957
+ return null;
4958
+ }
4959
+
4960
+ /**
4961
+ * Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
4962
+ * anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
4963
+ * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
4964
+ */
4965
+ async queryOwnerGrants(retry = true): Promise<OwnerGrantRow[]> {
4966
+ this.ensureInitialized();
4967
+ const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
4968
+ return rows.map(ownerGrantRowOf);
4969
+ }
4970
+
4971
+ /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
4972
+ async queryOwnerGrantUsages(retry = true): Promise<OwnerGrantUsageRow[]> {
4973
+ this.ensureInitialized();
4974
+ const rows = await this.readRows(
4975
+ 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage',
4976
+ undefined, 'owner-grant-usages', retry
4977
+ );
4978
+ return rows.map(ownerGrantUsageRowOf);
4979
+ }
4980
+
4981
+ /**
4982
+ * How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
4983
+ * through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
4984
+ * {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
4985
+ */
4986
+ async countOwnerGrantUsage(key: string, retry = true): Promise<number> {
4987
+ return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
4988
+ }
4989
+
4990
+ /**
4991
+ * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
4992
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
4993
+ * node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
4994
+ * when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
4995
+ * redemption's own write enforce every condition.
4996
+ */
4997
+ async isOwnerGrantLive(grant: OwnerGrantRow, nowMs: number = Date.now()): Promise<boolean> {
4998
+ this.ensureInitialized();
4999
+ const row = await this.queryOwnerGrant(grant.key) ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
5000
+ return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
5001
+ }
5002
+
5003
+ /**
5004
+ * Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
5005
+ * and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
5006
+ */
5007
+ async listOwnerGrantStatuses(nowMs: number = Date.now()): Promise<OwnerGrantStatus[]> {
5008
+ const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
5009
+ return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
5010
+ }
5011
+
5012
+ /**
5013
+ * Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
5014
+ * stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
5015
+ * and for the same reason (every key the grant seated proves itself through the row;
5016
+ * `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
5017
+ * refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
5018
+ * holder's copy on any node holding the tombstone. Keys already seated stay owners;
5019
+ * removing one is {@link deleteOwnerKey}.
5020
+ *
5021
+ * Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
5022
+ * `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
5023
+ * withdrawal committed while alone is re-issued on cohort growth.
5024
+ */
5025
+ async withdrawOwnerGrant(key: string, ownerKey: string, signMessage: ControlSign): Promise<boolean> {
5026
+ return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
5027
+ }
5028
+
5029
+ /**
5030
+ * Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
5031
+ * owner signature. One transaction writes the `OwnerKey` row (the grant branch of
5032
+ * `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
5033
+ * `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
5034
+ * row's `SeatedExists` reads the owner row back, so the two are bound to this exact
5035
+ * redemption and neither can be held in reserve. The grant must already be committed
5036
+ * ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
5037
+ * admitted and no peer id is named.
5038
+ *
5039
+ * The two signatures are minted where the private keys are (the grant's and the seated
5040
+ * key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
5041
+ * machine only verifies them, by writing the row.
5042
+ *
5043
+ * Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
5044
+ * `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
5045
+ * that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
5046
+ * the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
5047
+ * exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
5048
+ * except on a machine that still holds its retired row, which refuses by name
5049
+ * ({@link liveOwnerKeyStamp}).
5050
+ *
5051
+ * Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
5052
+ * inside the locked body every read passes `retry: false` and the stamp is minted per
5053
+ * attempt, so a {@link lockedWithRetry} re-run starts clean.
5054
+ *
5055
+ * @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
5056
+ * the schema's generic `Authorized` refusal); its `token` is the grant key.
5057
+ * {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
5058
+ */
5059
+ async redeemOwnerGrant(params: {
5060
+ grantKey: string;
5061
+ /** The owner key to seat (base64url ed25519): the holder generated it, or already holds it. */
5062
+ seatedKey: string;
5063
+ /** Single-use nonce minted by the holder; both signatures cover it. */
5064
+ usageStampId: string;
5065
+ /** The holder's signature with the grant private key over the `'redeem'` digest. */
5066
+ grantSig: string;
5067
+ /** The seated key's signature over the `'consent'` digest. */
5068
+ ownerSig: string;
5069
+ nowMs?: number;
5070
+ }): Promise<OwnerGrantRedemptionResult> {
5071
+ this.ensureInitialized();
5072
+ const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
5073
+ const nowMs = params.nowMs ?? Date.now();
5074
+ log('Redeeming owner grant %s for %s', grantKey, seatedKey);
5075
+
5076
+ return this.mutateCadrePeer('owner-grant-redeem', async () => {
5077
+ const grant = await this.queryOwnerGrant(grantKey, false);
5078
+ if (grant === null) {
5079
+ throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
5080
+ }
5081
+ const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
5082
+ if (heldStamp !== null) {
5083
+ log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
5084
+ return { alreadyOwner: true, seatedStampId: heldStamp };
5085
+ }
5086
+ await this.assertOwnerGrantSeatRemains(grant);
5087
+
5088
+ // Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
5089
+ // than re-presenting a stamp the usage row may already name.
5090
+ const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
5091
+ const nowCanonical = await canonicalDatetime(this.db!, nowMs);
5092
+ await this.inTransaction('owner-grant-redeem', async () => {
5093
+ await this.db!.exec(`
5094
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
5095
+ with context OwnerKey = null, Signature = null
5096
+ values (?, ?, ?, ?, null, ?)
5097
+ `, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
5098
+ await this.db!.exec(`
5099
+ insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
5100
+ with context Now = ?
5101
+ values (?, ?, ?, ?, ?, ?)
5102
+ `, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
5103
+ });
5104
+ log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
5105
+ return { alreadyOwner: false, seatedStampId };
5106
+ });
5107
+ }
5108
+
5109
+ /** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
5110
+ private assertOwnerGrantSeatRemains(grant: OwnerGrantRow): Promise<void> {
5111
+ return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
4628
5112
  }
4629
5113
 
4630
5114
  /**