@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  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 +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  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 +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  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 +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -7,19 +7,27 @@ 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 { createEmptyCollections } from '@serfab/quereus-plugin-sereus';
11
+ import type { CollectionRegistrySource } from '@serfab/quereus-plugin-sereus';
12
+ import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
13
+ import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, CONTROL_SCHEMA_NAME, controlSchemaFileDeclaration } from './control-schema.js';
14
+ import { refuseIncompatibleControlStore } from './control-schema-version.js';
12
15
  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';
17
- import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
16
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
17
+ import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDomain, ControlAction, CadreInviteSignedFields, OwnerGrantSignedFields } from './control-authorization.js';
18
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
19
+ import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
20
+ import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
21
+ import { retryControlWrite, LOST_UPDATE_RETRY_POLICY, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
18
22
  import type { ControlWriteRetryOptions } from './control-write-retry.js';
19
23
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
20
24
  import type { ControlReadRetryOptions } from './control-read-retry.js';
21
25
  import { chainMessages } from './control-retry.js';
22
26
  import type { ControlRetryAbandonment } from './control-retry.js';
27
+ import { TableWatchRegistry } from './table-change-watch.js';
28
+ import type { TableChangeWatch, TableChangeWatchOptions } from './table-change-watch.js';
29
+ import { MembershipView } from './membership-view.js';
30
+ import type { FormationInviteFacts } from './formation-token.js';
23
31
 
24
32
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
25
33
 
@@ -56,12 +64,11 @@ export function generateStampId(peerId: string): string {
56
64
  * present locally.
57
65
  *
58
66
  * Consent is bound to the strand's one-off `StampId` (see the consent branch of
59
- * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
60
- * The ordinary "host strand has not converged on this responder yet" case is caught
61
- * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
62
- * reaching here means the row vanished between that check and the write, which is a
63
- * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
64
- * CHECK at commit (which would drop the write with a far less legible error).
67
+ * `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
68
+ * inserting, and throws this when the row is absent rather than leaving it to fail the
69
+ * deferred `StrandExists` CHECK at commit (a far less legible error). The formation
70
+ * responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
71
+ * reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
65
72
  */
66
73
  export class MissingHostStrandError extends Error {
67
74
  constructor(readonly strandId: string, readonly token: string) {
@@ -97,22 +104,23 @@ export class FormationAbortedError extends Error {
97
104
  /**
98
105
  * The invitation's seat budget is already spent: the count of recorded usage rows for the
99
106
  * 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.
107
+ * is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
108
+ * table the caller redeemed against.
102
109
  *
103
110
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
104
111
  * the write, inside {@link ControlDatabase.redeemInvitation} /
105
112
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
106
- * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
107
- * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
108
- * pass — and off nothing else. It exists because
113
+ * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
114
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
115
+ * `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
116
+ * manager issues a membership pass — and off nothing else. It exists because
109
117
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
110
118
  * schema's own count-based cap clause, which the manager reports as a retryable
111
119
  * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
112
120
  * Two redemptions of one token racing on the SAME node serialize behind the local write
113
- * queue, so the loser reads the winner's committed row and is refused here by name —
114
- * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
115
- * ran before either redemption wrote anything.
121
+ * queue, so the loser reads the winner's committed row and is refused here by name — the
122
+ * use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
123
+ * read before either redemption wrote anything.
116
124
  */
117
125
  export class InvitationExhaustedError extends Error {
118
126
  constructor(
@@ -148,8 +156,51 @@ export class CadreInviteIssuerUnknownError extends Error {
148
156
  }
149
157
  }
150
158
 
151
- /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
152
- const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
159
+ /**
160
+ * {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
161
+ * grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
162
+ * `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
163
+ * Its own class rather than a shared one so a caller that handles invitation and grant
164
+ * bundles can tell by `instanceof` which credential was refused.
165
+ */
166
+ export class OwnerGrantIssuerUnknownError extends Error {
167
+ constructor(
168
+ readonly grantKey: string,
169
+ readonly issuerKey: string,
170
+ options?: { cause?: unknown }
171
+ ) {
172
+ super(
173
+ `Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`,
174
+ options
175
+ );
176
+ this.name = 'OwnerGrantIssuerUnknownError';
177
+ }
178
+ }
179
+
180
+ /**
181
+ * An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
182
+ * yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
183
+ * insert refused by a cohort member already holding the row, and a refreshed read here still
184
+ * finds nothing; or the redemption that follows the seat finds no row. It clears once this
185
+ * node's store catches up, which is why the redemption paths answer it with the retryable
186
+ * `conflict` code.
187
+ */
188
+ export class IssuedRowNotHereYetError extends Error {
189
+ constructor(
190
+ readonly table: IssuedTable,
191
+ readonly key: string,
192
+ options?: { cause?: unknown }
193
+ ) {
194
+ super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
195
+ this.name = 'IssuedRowNotHereYetError';
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
201
+ * wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
202
+ */
203
+ const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
153
204
 
154
205
  /**
155
206
  * What a recorded `FormationUsage` row came out as.
@@ -370,6 +421,51 @@ export function cadreInviteConsentMessage(partyId: string, fields: CadreInviteRe
370
421
  ]);
371
422
  }
372
423
 
424
+ /**
425
+ * The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
426
+ * digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
427
+ * nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
428
+ * `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
429
+ * canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
430
+ * the reason on {@link cadreInviteAddMessage}.
431
+ */
432
+ export function ownerGrantAddMessage(partyId: string, row: OwnerGrantSignedFields): Uint8Array {
433
+ return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
434
+ }
435
+
436
+ /** The fields both owner-grant redemption signatures cover; see {@link ownerGrantRedeemMessage}. */
437
+ export interface OwnerGrantRedemptionFields {
438
+ grantKey: string;
439
+ /** The single-use nonce the holder minted for this redemption (`generateStampId`). */
440
+ usageStampId: string;
441
+ /** The owner key being seated (base64url ed25519). */
442
+ seatedKey: string;
443
+ }
444
+
445
+ /**
446
+ * The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
447
+ * it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
448
+ * against the grant's public key. Binding the seated key and the nonce means a captured
449
+ * redemption can seat no other key and spend no other use.
450
+ */
451
+ export function ownerGrantRedeemMessage(partyId: string, fields: OwnerGrantRedemptionFields): Uint8Array {
452
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
453
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
454
+ ]);
455
+ }
456
+
457
+ /**
458
+ * The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
459
+ * `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
460
+ * fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
461
+ * signature can stand in for the other.
462
+ */
463
+ export function ownerGrantConsentMessage(partyId: string, fields: OwnerGrantRedemptionFields): Uint8Array {
464
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
465
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
466
+ ]);
467
+ }
468
+
373
469
  /**
374
470
  * Minimal interface for the CollectionFactory returned by the optimystic plugin.
375
471
  * We only need the methods we actually use.
@@ -393,6 +489,8 @@ interface OptimysticPluginResult {
393
489
  * compose-strand.ts).
394
490
  */
395
491
  hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
492
+ /** The transaction bridge, whose registry holds every collection a control table opened; see {@link ControlDatabase.ensureOwnerKey}. */
493
+ txnBridge: CollectionRegistrySource;
396
494
  [key: string]: unknown;
397
495
  }
398
496
 
@@ -418,14 +516,23 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
418
516
  JoinRequest: 'Id',
419
517
  DeviceToken: 'PeerId',
420
518
  CadreInvite: 'Key',
519
+ OwnerGrant: 'Key',
421
520
  };
422
521
 
423
522
  /**
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}).
523
+ * A guarded table of owner-issued credentials: signed rows that travel inside a bundle, are
524
+ * seated verbatim by any member ({@link ControlDatabase.seatCadreInvite},
525
+ * {@link ControlDatabase.seatOwnerGrant}), and are never deleted (`NoDelete`) because the
526
+ * rows they admitted prove themselves through them. Withdrawing one is a tombstone alone
527
+ * ({@link ControlDatabase.withdrawCadreInvite}, {@link ControlDatabase.withdrawOwnerGrant}).
427
528
  */
428
- type RemovableTable = Exclude<RevocableTable, 'CadreInvite'>;
529
+ type IssuedTable = Extract<RevocableTable, 'CadreInvite' | 'OwnerGrant'>;
530
+
531
+ /** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
532
+ const ISSUED_TABLE_REASON: Readonly<Record<IssuedTable, string>> = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
533
+
534
+ /** A guarded table an owner may remove a row from: every revocable table but the {@link IssuedTable}s. */
535
+ type RemovableTable = Exclude<RevocableTable, IssuedTable>;
429
536
 
430
537
  /**
431
538
  * One row incarnation's owner-signed removal, ready for {@link ControlDatabase.execGuardedRemoval}:
@@ -442,10 +549,9 @@ interface GuardedRemoval {
442
549
 
443
550
  /**
444
551
  * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
445
- * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
446
- * tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
447
- * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
448
- * `revocationDigest`.
552
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by both
553
+ * tombstone writers (a guarded removal, an issued row's withdrawal) so the field order lives
554
+ * in one place; the base64url verifier is `peer-authorization.ts`'s `revocationDigest`.
449
555
  */
450
556
  function revocationTombstoneMessage(partyId: string, ref: { tableName: string; rowKey: string; stampId: string }): Uint8Array {
451
557
  return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
@@ -455,42 +561,142 @@ function revocationTombstoneMessage(partyId: string, ref: { tableName: string; r
455
561
  * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
456
562
  * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
457
563
  */
458
- function ownerKeyAddMessage(partyId: string, key: string, stampId: string): Uint8Array {
459
- return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
564
+ function ownerKeyAddMessage(partyId: string, key: string, label: string | null, stampId: string): Uint8Array {
565
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
460
566
  }
461
567
 
462
- function signGuardedRemoval(
568
+ /**
569
+ * An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
570
+ * {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
571
+ * Idempotent, so a caller that checks before opening an owner approval can pass the result on.
572
+ */
573
+ export function ownerKeyLabel(label: string | null | undefined): string | null {
574
+ const trimmed = label?.trim();
575
+ if (!trimmed) {
576
+ return null;
577
+ }
578
+ if (trimmed.length > OWNER_KEY_MAX_LABEL) {
579
+ throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
580
+ }
581
+ return trimmed;
582
+ }
583
+
584
+ /**
585
+ * Signs one owner digest's raw bytes (no pre-hash) and returns a base64url Ed25519 signature:
586
+ * the callback every owner-signed writer takes. An `OwnerSign` from an open
587
+ * `OwnerSigner.approve` fits it, and so does a synchronous signer.
588
+ *
589
+ * NOTE: writers that read the stamp they sign do so inside the write lock, so the lock is held
590
+ * across these calls, and a `lockedWithRetry` re-run signs again inside the same approval.
591
+ * Free for a software signer. If a prompting signer ever holds the lock long enough to stall
592
+ * other control writes, sign outside the lock and re-check the stamp inside it.
593
+ */
594
+ export type ControlSign = (message: Uint8Array) => string | Promise<string>;
595
+
596
+ /**
597
+ * Signs this node's own address record over its `CadrePeer` row as it stands —
598
+ * {@link ControlDatabase.updateSelfPeerRecord}'s callback, run inside the locked write body on
599
+ * every attempt, so the record written is always stamped past the row that was actually read.
600
+ */
601
+ export type SelfRecordSigner = (current: PeerAddressRecord) => PeerAddressRecord;
602
+
603
+ /**
604
+ * Which owner re-touch {@link ControlDatabase.reauthorizeCadrePeer} makes over a `CadrePeer` row:
605
+ *
606
+ * - `'rebroadcast'` — the write-while-alone drain re-emitting a row that committed local-only. It
607
+ * bumps `UpdatedAt` so the row goes out as a fresh transaction, and writes only while the row's
608
+ * `Sig` is null: once the machine has self-published, the row is its to refresh, and a bump
609
+ * without a re-sign would leave its signature unverifiable.
610
+ * - `'revouch'` — an owner rebinding the row's voucher to its own key before the vouching key is
611
+ * removed (`CadreNode.removeOwner`). Written whether or not the machine has self-published, and
612
+ * leaves `UpdatedAt` untouched, so a self-signed record stays verifiable.
613
+ */
614
+ export type CadrePeerRetouchMode = 'rebroadcast' | 'revouch';
615
+
616
+ /**
617
+ * What {@link ControlDatabase.reauthorizeCadrePeer} found and did: the row was re-touched; no
618
+ * readable row exists (absent, or its stamp retired); or — `'rebroadcast'` only — the machine has
619
+ * self-published since, so the row is no longer the owner's to re-emit. The two no-write outcomes
620
+ * are decided over the row as read inside the locked body, so a caller can act on them.
621
+ */
622
+ export type CadrePeerRetouchOutcome = 'retouched' | 'absent' | 'self-published';
623
+
624
+ /** A formation invitation written with a founding ({@link FoundingRows}), bound to the strand founded. */
625
+ export interface FoundingInvite {
626
+ /** The invitation token, the `FormationInvite` primary key. */
627
+ token: string;
628
+ /** The sApp a redeemed strand will use. */
629
+ sAppId: string;
630
+ /** Epoch ms after which it cannot be redeemed; omit for never. */
631
+ expiresAtMs?: number;
632
+ /** How many redemptions it allows; omit for unlimited. */
633
+ totalUses?: number;
634
+ /** Web hook sent the disclosure; a `ValidationKey` then signs off each redemption. */
635
+ validationUrl?: string;
636
+ }
637
+
638
+ /** The control rows one founding writes together; see {@link ControlDatabase.insertFoundingRows}. */
639
+ export interface FoundingRows {
640
+ strand: { id: string; type: 'o' | 'c'; memberPrivateKey?: string };
641
+ /** This party's membership identity key for a closed strand; absent for an open strand, or when a stored key is adopted. */
642
+ partyKey?: string;
643
+ /** A formation invitation bound to this strand (its `StrandId` is `strand.id`). */
644
+ formationInvite?: FoundingInvite;
645
+ }
646
+
647
+ /** One owner-signed insert, stamped and signed: the statement and parameters to run under the write lock. */
648
+ interface SignedInsert {
649
+ sql: string;
650
+ params: SqlParameters;
651
+ }
652
+
653
+ /** What an owner asks {@link ControlDatabase.insertCadreInvite} (or {@link ControlDatabase.signCadreInvite}) to sign. */
654
+ export interface CadreInviteFields {
655
+ /** The invitation public key (base64url ed25519), the row's primary key. */
656
+ key: string;
657
+ /** The device the invitation is for, or null for whoever redeems first. */
658
+ peerId?: string | null;
659
+ /** Epoch ms after which it cannot be redeemed; omit for never. */
660
+ expiresAtMs?: number | null;
661
+ /** How many redemptions it allows; omit for unlimited. */
662
+ totalUses?: number | null;
663
+ }
664
+
665
+ async function signGuardedRemoval(
463
666
  partyId: string,
464
667
  tableName: RemovableTable,
465
668
  rowKey: string,
466
669
  stampId: string,
467
- signMessage: (message: Uint8Array) => string
468
- ): GuardedRemoval {
670
+ signMessage: ControlSign
671
+ ): Promise<GuardedRemoval> {
469
672
  const ref = { tableName, rowKey, stampId };
470
673
  return {
471
674
  ref,
472
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
473
- revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
675
+ signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
676
+ revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
474
677
  };
475
678
  }
476
679
 
477
680
  /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
478
- type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
681
+ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'StrandPartyKey' | 'JoinedStrand'>;
479
682
 
480
- /** The control tables keyed by an `Id` column whose repeat insert a caller may classify. */
481
- type IdKeyedTable = StrandIdTable | Extract<RevocableTable, 'JoinRequest'>;
683
+ /** The control tables whose repeat insert of a held key a caller may classify. */
684
+ type KeyConflictTable = StrandIdTable | IssuedTable | Extract<RevocableTable, 'JoinRequest'>;
482
685
 
483
686
  /**
484
- * The rejection a second insert of an already-seated id into `table` produces, as the
687
+ * The rejection a second insert of an already-seated key into `table` produces, as the
485
688
  * optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
486
- * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
689
+ * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
690
+ * naming the table's {@link GUARDED_KEY_COLUMN}.
487
691
  *
488
692
  * 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.
693
+ * only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
694
+ * stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
695
+ * Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
696
+ * idempotency branch.
491
697
  */
492
- function idConflictPattern(table: IdKeyedTable): RegExp {
493
- return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
698
+ function keyConflictPattern(table: KeyConflictTable): RegExp {
699
+ return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
494
700
  }
495
701
 
496
702
  /**
@@ -499,7 +705,8 @@ function idConflictPattern(table: IdKeyedTable): RegExp {
499
705
  * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
500
706
  * landed" (re-read, and no-op when the row matches) apart from every other rejection —
501
707
  * an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
502
- * the same test tells "another machine of this party already published this join" apart.
708
+ * the same test tells "another machine of this party already published this join" apart, and
709
+ * for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
503
710
  *
504
711
  * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
505
712
  * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
@@ -509,7 +716,17 @@ function idConflictPattern(table: IdKeyedTable): RegExp {
509
716
  * reword reddens there.
510
717
  */
511
718
  export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolean {
512
- return errorChainMatches(error, idConflictPattern(table));
719
+ return errorChainMatches(error, keyConflictPattern(table));
720
+ }
721
+
722
+ /**
723
+ * Did a seat of an issued row fail because a cohort member already holds a row under its key?
724
+ * {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
725
+ * on a node whose store lags the cohort: its guard read missed the row, and the member that
726
+ * checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
727
+ */
728
+ export function isIssuedRowKeyConflict(error: unknown, table: IssuedTable): boolean {
729
+ return errorChainMatches(error, keyConflictPattern(table));
513
730
  }
514
731
 
515
732
  /**
@@ -522,7 +739,7 @@ export function isStrandIdConflict(error: unknown, table: StrandIdTable): boolea
522
739
  * until the next reap pass.
523
740
  */
524
741
  export function isPendingJoinConflict(error: unknown): boolean {
525
- return errorChainMatches(error, idConflictPattern('JoinRequest'));
742
+ return errorChainMatches(error, keyConflictPattern('JoinRequest'));
526
743
  }
527
744
 
528
745
  /**
@@ -556,31 +773,17 @@ export class PendingJoinChangedError extends Error {
556
773
  }
557
774
 
558
775
  /**
559
- * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
560
- * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
561
- * block. The schema's table comment says why that matters and why the row can never read
562
- * as a retirement; `RowIsGone` pins this exact triple.
776
+ * A `CadreControl.Revocation` row that retires nothing, which parties founded before
777
+ * `control-collections-created-at-cadre-founding` may hold: an owner filed it so the table
778
+ * had a collection, which genesis now creates. Nothing files it now. `RowIsGone` still admits
779
+ * this exact triple, and {@link ControlDatabase.queryRevocations} skips it.
563
780
  */
564
- export const REVOCATION_LEDGER_MARKER = {
781
+ const REVOCATION_LEDGER_MARKER = {
565
782
  tableName: 'Revocation',
566
783
  rowKey: 'ledger',
567
784
  stampId: 'opened',
568
785
  } as const;
569
786
 
570
- /**
571
- * 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
573
- * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
574
- * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
575
- * has no other unique constraint.
576
- */
577
- const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
578
-
579
- /** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
580
- function isRevocationLedgerConflict(error: unknown): boolean {
581
- return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
582
- }
583
-
584
787
  /** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
585
788
  function errorChainMatches(error: unknown, pattern: RegExp): boolean {
586
789
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
@@ -588,42 +791,33 @@ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
588
791
 
589
792
  /**
590
793
  * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
591
- * redemption reply, carries.
794
+ * redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
795
+ * redemption; it contributes an entry only through its own `CadrePeer` row.
592
796
  *
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.
797
+ * A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
798
+ * receiver at that moment, since it is delivering the seed or the reply, or an always-on
799
+ * machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
800
+ * reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
801
+ * own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
802
+ * machine misdeclaring one costs the receiver a wasted dial at most.
605
803
  */
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
- });
804
+ export function projectSeedPeers(rows: readonly CadrePeerRow[], minterPeerId: string): SeedPeer[] {
805
+ return rows.map(({ peerId, multiaddr, profile }) => ({
806
+ peerId,
807
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
808
+ preferred: peerId === minterPeerId || profile === 'storage',
809
+ }));
617
810
  }
618
811
 
619
812
  /**
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`.
813
+ * The conditions an issued credential (a cadre invitation or an owner grant) must still meet
814
+ * to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
815
+ * expired, as the redemption refuses it), and issued by a current owner. Shared by
816
+ * {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
817
+ * {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
818
+ * drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
625
819
  */
626
- function cadreInviteStillOpen(
820
+ function issuedRowStillOpen(
627
821
  row: { stampId: string; issuerKey: string; expiresAt: string | null },
628
822
  withdrawn: ReadonlySet<string>,
629
823
  owners: ReadonlySet<string>,
@@ -647,8 +841,8 @@ function cadreInviteStillOpen(
647
841
  * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
648
842
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
649
843
  * 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.
844
+ * party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
845
+ * tombstones are withdrawals over rows that stay), so there is nothing to reap.
652
846
  */
653
847
  export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'] as const satisfies readonly RevocableTable[];
654
848
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
@@ -656,26 +850,109 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
656
850
  /**
657
851
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
658
852
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
659
- * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
853
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
660
854
  * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
661
855
  * log line per skip would be per-pass noise.
662
856
  */
663
857
  const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(REAPABLE_TABLES);
664
858
  const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
665
859
 
666
- /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
667
- const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
860
+ /** One control read's rows, and whether it took the committed-read path ({@link ControlDatabase.readRowsOnce}). */
861
+ interface RoutedRead {
862
+ rows: Record<string, SqlValue>[];
863
+ committed: boolean;
864
+ }
865
+
866
+ /**
867
+ * The full-table scans the `Revocation`, `CadrePeer` and `OwnerKey` membership views load
868
+ * ({@link ControlDatabase.readMembershipRows}); the four chain tables' scans sit beside their
869
+ * row mappers below. Specs rig these reads by their text: `from CadreControl.Revocation`, and
870
+ * the `CadrePeer` scan by its `select PeerId, Multiaddr, StampId` prefix, which no other
871
+ * `CadrePeer` statement shares.
872
+ */
873
+ const REVOCATION_SELECT_SQL = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
874
+ const CADRE_PEER_SELECT_SQL = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
875
+ const OWNER_KEY_SELECT_SQL = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
876
+
877
+ /** A stored `CadrePeer` row as a {@link CadrePeerRow}. */
878
+ function cadrePeerRowOf(row: Record<string, SqlValue>): CadrePeerRow {
879
+ return {
880
+ peerId: row.PeerId as string,
881
+ publicKey: (row.PublicKey as string | null) ?? null,
882
+ multiaddr: (row.Multiaddr as string | null) ?? null,
883
+ updatedAt: (row.UpdatedAt as number | null) ?? 0,
884
+ profile: (row.Profile as NodeProfile | null) ?? null,
885
+ sig: (row.Sig as string | null) ?? null,
886
+ stampId: (row.StampId as string | null) ?? null,
887
+ vouchOwner: (row.VouchOwner as string | null) ?? null,
888
+ vouchSig: (row.VouchSig as string | null) ?? null,
889
+ vouchUsage: (row.VouchUsage as string | null) ?? null,
890
+ };
891
+ }
892
+
893
+ /** A {@link CadrePeerRow} as the address record its machine signed ({@link PeerAddressRecord}); see {@link ControlDatabase.queryPeerRecord} for the coalescing. */
894
+ function peerAddressRecordOf(row: CadrePeerRow): PeerAddressRecord {
895
+ const multiaddr = row.multiaddr ?? '';
896
+ return {
897
+ peerId: row.peerId,
898
+ publicKey: row.publicKey ?? '',
899
+ addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
900
+ updatedAt: row.updatedAt,
901
+ profile: row.profile,
902
+ sig: row.sig ?? '',
903
+ };
904
+ }
905
+
906
+ /** The columns {@link ControlDatabase.reauthorizeCadrePeer} signs over and bumps: a row it may write. */
907
+ interface RetouchTarget {
908
+ stampId: string;
909
+ updatedAt: number;
910
+ }
911
+
912
+ /**
913
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s decision over the row as read: the row to write,
914
+ * or the {@link CadrePeerRetouchOutcome} that says why nothing is written — no readable row (a
915
+ * stampless row counts, since the voucher digest binds the stamp), or a self-published row in
916
+ * `'rebroadcast'` mode. One function so the pre-check outside the lock and the re-check inside it
917
+ * cannot disagree.
918
+ */
919
+ function retouchTarget(row: CadrePeerRow | null, mode: CadrePeerRetouchMode): RetouchTarget | 'absent' | 'self-published' {
920
+ if (row === null || row.stampId === null) {
921
+ return 'absent';
922
+ }
923
+ if (mode === 'rebroadcast' && row.sig !== null) {
924
+ return 'self-published';
925
+ }
926
+ return { stampId: row.stampId, updatedAt: row.updatedAt };
927
+ }
928
+
929
+ /** A stored `Revocation` row as a {@link RevocationRow}; the caller has already skipped {@link REVOCATION_LEDGER_MARKER}. */
930
+ function revocationRowOf(row: Record<string, SqlValue>): RevocationRow {
931
+ return {
932
+ tableName: row.TableName as RevocableTable,
933
+ rowKey: row.RowKey as string,
934
+ stampId: row.StampId as string,
935
+ reissuedAt: (row.ReissuedAt as number | null) ?? 0,
936
+ signerKey: row.SignerKey as string,
937
+ signerSig: row.SignerSig as string,
938
+ };
939
+ }
940
+
941
+ /**
942
+ * Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them: the full scan the
943
+ * `CadreInvite` membership view loads, and the base the point reads append their `where` to.
944
+ */
945
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
668
946
 
669
947
  /**
670
948
  * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
671
949
  * 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.
950
+ * parsed to ms here.
673
951
  */
674
952
  function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
675
953
  return {
676
954
  key: row.Key as string,
677
955
  peerId: (row.PeerId as string | null) ?? null,
678
- grantsOwner: Number(row.GrantsOwner) === 1,
679
956
  expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
680
957
  totalUses: (row.TotalUses as number | null) ?? null,
681
958
  issuerKey: row.IssuerKey as string,
@@ -684,6 +961,9 @@ function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
684
961
  };
685
962
  }
686
963
 
964
+ /** The full scan the `CadreInviteUsage` membership view loads, in the order {@link cadreInviteUsageRowOf} reads it. */
965
+ const CADRE_INVITE_USAGE_SELECT_SQL = 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage';
966
+
687
967
  /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
688
968
  function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageRow {
689
969
  return {
@@ -692,12 +972,50 @@ function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageR
692
972
  peerId: row.PeerId as string,
693
973
  peerKey: row.PeerKey as string,
694
974
  peerStampId: row.PeerStampId as string,
695
- ownerStampId: (row.OwnerStampId as string | null) ?? null,
696
975
  inviteSig: row.InviteSig as string,
697
976
  peerSig: row.PeerSig as string,
698
977
  };
699
978
  }
700
979
 
980
+ /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; as {@link CADRE_INVITE_SELECT_SQL}, the view's scan and the point read's base. */
981
+ const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
982
+
983
+ /** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
984
+ function ownerGrantRowOf(row: Record<string, SqlValue>): OwnerGrantRow {
985
+ return {
986
+ key: row.Key as string,
987
+ label: (row.Label as string | null) ?? null,
988
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
989
+ totalUses: (row.TotalUses as number | null) ?? null,
990
+ issuerKey: row.IssuerKey as string,
991
+ issuerSig: row.IssuerSig as string,
992
+ stampId: row.StampId as string,
993
+ };
994
+ }
995
+
996
+ /** The full scan the `OwnerGrantUsage` membership view loads, in the order {@link ownerGrantUsageRowOf} reads it. */
997
+ const OWNER_GRANT_USAGE_SELECT_SQL = 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage';
998
+
999
+ /** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
1000
+ function ownerGrantUsageRowOf(row: Record<string, SqlValue>): OwnerGrantUsageRow {
1001
+ return {
1002
+ usageStampId: row.UsageStampId as string,
1003
+ grantKey: row.GrantKey as string,
1004
+ seatedKey: row.SeatedKey as string,
1005
+ seatedStampId: row.SeatedStampId as string,
1006
+ grantSig: row.GrantSig as string,
1007
+ ownerSig: row.OwnerSig as string,
1008
+ };
1009
+ }
1010
+
1011
+ /** One issued row's standing, as {@link ControlDatabase.listCadreInviteStatuses} and {@link ControlDatabase.listOwnerGrantStatuses} report it. */
1012
+ interface IssuedRowStanding<Row> {
1013
+ row: Row;
1014
+ live: boolean;
1015
+ withdrawn: boolean;
1016
+ usesRecorded: number;
1017
+ }
1018
+
701
1019
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
702
1020
  function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
703
1021
  return {
@@ -705,20 +1023,26 @@ function joinedStrandRow(row: Record<string, SqlValue>): StrandRow {
705
1023
  MemberPrivateKey: row.MemberPrivateKey as string | null,
706
1024
  Type: row.Type as 'o' | 'c',
707
1025
  FounderOwnerKey: null,
1026
+ FounderPeerId: null,
1027
+ };
1028
+ }
1029
+
1030
+ /** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
1031
+ function strandRow(row: Record<string, SqlValue>): StrandRow {
1032
+ return {
1033
+ Id: row.Id as string,
1034
+ MemberPrivateKey: row.MemberPrivateKey as string | null,
1035
+ Type: row.Type as 'o' | 'c',
1036
+ FounderOwnerKey: row.FounderOwnerKey as string | null,
1037
+ FounderPeerId: row.FounderPeerId as string | null,
708
1038
  };
709
1039
  }
710
1040
 
1041
+ const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
1042
+
711
1043
  /** A `FormationInvite` row as {@link ControlDatabase.queryFormationInvite} reads it. */
712
- export interface FormationInviteRow {
1044
+ export interface FormationInviteRow extends FormationInviteFacts {
713
1045
  token: string;
714
- sAppId: string;
715
- /** Parsed epoch ms of the stored `datetime`; null when the invitation never expires. */
716
- expiresAtMs: number | null;
717
- /** Null is unlimited. */
718
- totalUses: number | null;
719
- validationUrl: string | null;
720
- /** A bound invitation's host strand; null on the responder-provisions path. */
721
- strandId: string | null;
722
1046
  }
723
1047
 
724
1048
  const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
@@ -805,13 +1129,13 @@ interface SignedStatement {
805
1129
  * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
806
1130
  * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
807
1131
  */
808
- function signJoinOutcomeInsert(
1132
+ async function signJoinOutcomeInsert(
809
1133
  partyId: string,
810
1134
  requestStampId: string,
811
1135
  outcome: JoinOutcome,
812
1136
  ownerKey: string,
813
- signMessage: (message: Uint8Array) => string
814
- ): SignedStatement {
1137
+ signMessage: ControlSign
1138
+ ): Promise<SignedStatement> {
815
1139
  const at = String(outcome.RecordedAt);
816
1140
  const { table, sql, fields, values } = outcome.kind === 'joined'
817
1141
  ? {
@@ -824,7 +1148,7 @@ function signJoinOutcomeInsert(
824
1148
  fields: [requestStampId, at, outcome.Code, outcome.Reason],
825
1149
  values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
826
1150
  };
827
- const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
1151
+ const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
828
1152
  return { sql, params: [ownerKey, signature, ...values] };
829
1153
  }
830
1154
 
@@ -848,9 +1172,6 @@ export type MembershipChangeListener = (reason: string) => Promise<void>;
848
1172
  */
849
1173
  export type RevokedRowRef = Pick<RevocationRow, 'tableName' | 'rowKey' | 'stampId'>;
850
1174
 
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
1175
  /**
855
1176
  * Notified after a guarded-table delete — and the `CadreControl.Revocation`
856
1177
  * tombstone it writes in the same transaction — has COMMITTED.
@@ -878,6 +1199,30 @@ export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
878
1199
  */
879
1200
  export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
880
1201
 
1202
+ /** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
1203
+ async function readSchemaFile(schemaPath: string): Promise<string> {
1204
+ if (typeof process === 'undefined' || !process.versions?.node) {
1205
+ throw new Error(
1206
+ 'Loading schema from file is not supported in React Native. ' +
1207
+ 'Remove the schemaPath option to use the embedded schema instead.'
1208
+ );
1209
+ }
1210
+ try {
1211
+ // Use require to conditionally load fs only in Node.js.
1212
+ // This won't be bundled by React Native's Metro bundler — a dynamic
1213
+ // import() would be statically picked up by Metro, so require is
1214
+ // intentional here (cross-platform constraint, not lazy CommonJS).
1215
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1216
+ const fs = require('fs/promises');
1217
+ return await fs.readFile(schemaPath, 'utf-8');
1218
+ } catch (error) {
1219
+ throw new Error(
1220
+ `Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
1221
+ { cause: error }
1222
+ );
1223
+ }
1224
+ }
1225
+
881
1226
  export interface ControlDatabaseConfig {
882
1227
  /**
883
1228
  * Party ID for the control network. Also the party every owner and peer signature over a
@@ -890,6 +1235,8 @@ export interface ControlDatabaseConfig {
890
1235
  * Optional path to the control schema file.
891
1236
  * If not provided, uses the embedded schema for cross-platform compatibility.
892
1237
  * Only use this if you need to override the default schema (e.g., for testing).
1238
+ * The file has the shape of `schemas/control.qsql`: one `declare schema CadreControl`
1239
+ * followed by one plain `apply schema CadreControl` ({@link controlSchemaFileDeclaration}).
893
1240
  */
894
1241
  schemaPath?: string;
895
1242
  /** Libp2p node for the control network (injected) */
@@ -905,6 +1252,7 @@ export interface ControlDatabaseConfig {
905
1252
  export class ControlDatabase {
906
1253
  private db: Database | null = null;
907
1254
  private collectionFactory: CollectionFactory | null = null;
1255
+ private txnBridge: CollectionRegistrySource | null = null;
908
1256
  private readonly config: ControlDatabaseConfig;
909
1257
  private initialized = false;
910
1258
  private membershipListener: MembershipChangeListener | null = null;
@@ -912,6 +1260,26 @@ export class ControlDatabase {
912
1260
  private controlWriteAbandonedListener: ControlWriteAbandonedListener | null = null;
913
1261
  /** Tail of the local-write chain — see {@link withWriteLock}. */
914
1262
  private writeQueue: Promise<unknown> = Promise.resolve();
1263
+ /** Subscriptions handed out by {@link watchTables}. */
1264
+ private readonly tableWatches = new TableWatchRegistry();
1265
+ /** The cached membership tables; see {@link readMembershipRows}. */
1266
+ private readonly revocationView = this.membershipView(REVOCATION_SELECT_SQL, 'revocations');
1267
+ private readonly cadrePeerView = this.membershipView(CADRE_PEER_SELECT_SQL, 'cadre-peers');
1268
+ private readonly ownerKeyView = this.membershipView(OWNER_KEY_SELECT_SQL, 'owner-keys');
1269
+ private readonly cadreInviteView = this.membershipView(CADRE_INVITE_SELECT_SQL, 'cadre-invites');
1270
+ private readonly cadreInviteUsageView = this.membershipView(CADRE_INVITE_USAGE_SELECT_SQL, 'cadre-invite-usages');
1271
+ private readonly ownerGrantView = this.membershipView(OWNER_GRANT_SELECT_SQL, 'owner-grants');
1272
+ private readonly ownerGrantUsageView = this.membershipView(OWNER_GRANT_USAGE_SELECT_SQL, 'owner-grant-usages');
1273
+ /** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
1274
+ private readonly membershipViews = new Map<string, MembershipView<Record<string, SqlValue>>>([
1275
+ ['CadreControl.Revocation', this.revocationView],
1276
+ ['CadreControl.CadrePeer', this.cadrePeerView],
1277
+ ['CadreControl.OwnerKey', this.ownerKeyView],
1278
+ ['CadreControl.CadreInvite', this.cadreInviteView],
1279
+ ['CadreControl.CadreInviteUsage', this.cadreInviteUsageView],
1280
+ ['CadreControl.OwnerGrant', this.ownerGrantView],
1281
+ ['CadreControl.OwnerGrantUsage', this.ownerGrantUsageView],
1282
+ ]);
915
1283
  /**
916
1284
  * Locked write bodies currently running (see {@link runWriteBody}); what
917
1285
  * {@link readRowsOnce} consults to route an unlocked read around a write that has
@@ -951,6 +1319,7 @@ export class ControlDatabase {
951
1319
  }
952
1320
 
953
1321
  log('Initializing ControlDatabase for party: %s', this.config.partyId);
1322
+ this.invalidateMembershipViews();
954
1323
 
955
1324
  // Create database instance
956
1325
  this.db = new Database();
@@ -984,6 +1353,7 @@ export class ControlDatabase {
984
1353
  registerPartyIdFunction(this.db, this.config.partyId);
985
1354
 
986
1355
  this.collectionFactory = pluginResult.collectionFactory;
1356
+ this.txnBridge = pluginResult.txnBridge;
987
1357
 
988
1358
  // Inject the libp2p node into the collection factory
989
1359
  t0 = performance.now();
@@ -1040,54 +1410,82 @@ export class ControlDatabase {
1040
1410
  await this.loadSchema();
1041
1411
  timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
1042
1412
 
1413
+ this.watchMembershipViews();
1043
1414
  this.initialized = true;
1044
1415
  log('ControlDatabase initialized successfully');
1045
1416
  }
1046
1417
 
1047
- private async loadSchema(): Promise<void> {
1048
- let schemaContent: string;
1418
+ /**
1419
+ * Drop a membership view whenever its table changes: a commit made here, or a change this
1420
+ * node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
1421
+ * does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
1422
+ * subscription cannot hear. {@link close} disposes it with the rest.
1423
+ */
1424
+ private watchMembershipViews(): void {
1425
+ this.tableWatches.watch(this.db!, [...this.membershipViews.keys()], () => Promise.resolve(), {
1426
+ label: 'membership-view',
1427
+ onInvalidate: (table) => this.membershipViews.get(table)?.invalidate(),
1428
+ });
1429
+ }
1430
+
1431
+ /**
1432
+ * {@link watchTables} on the membership tables: the seven the membership views copy
1433
+ * (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
1434
+ * `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
1435
+ * read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
1436
+ * reads can depend on and no list of them lives outside this class.
1437
+ */
1438
+ watchMembershipTables(onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
1439
+ return this.watchTables([...this.membershipViews.keys()], onChange, options);
1440
+ }
1049
1441
 
1442
+ /**
1443
+ * Empty every membership view, so the next read of each loads afresh and no load in
1444
+ * flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
1445
+ * a sibling becomes reachable (its identify on a new connection): a machine that was offline
1446
+ * while that sibling committed learns of the commit only when it next reads the block from
1447
+ * storage, and dropping the views makes the pass that starts then read now rather than after
1448
+ * `MEMBERSHIP_VIEW_MAX_AGE_MS`.
1449
+ */
1450
+ invalidateMembershipViews(): void {
1451
+ for (const view of this.membershipViews.values()) {
1452
+ view.invalidate();
1453
+ }
1454
+ }
1455
+
1456
+ private async loadSchema(): Promise<void> {
1457
+ let declaration: string;
1050
1458
  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
1459
  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
- }
1460
+ declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
1077
1461
  } else {
1078
- // Use embedded schema for cross-platform compatibility
1079
1462
  log('Using embedded control schema');
1080
- schemaContent = CONTROL_SCHEMA;
1463
+ declaration = CONTROL_SCHEMA_DECLARATION;
1081
1464
  }
1082
1465
 
1466
+ // Declaring runs no DDL; it only records the declaration the check and the apply both read.
1467
+ await this.db!.exec(declaration);
1468
+ // The apply below is also the MIGRATION of a warm store written under an older control
1469
+ // schema version: its diff runs each version's changes in order, every one applied to a
1470
+ // table's catalog record without a row rewritten, and ends by recording this build's version
1471
+ // on the store. A store the apply must not touch is refused HERE, before it runs anything: one
1472
+ // a newer release migrated (this machine is the one to update), one from before migrations,
1473
+ // or one whose diff would rewrite or discard rows (`control-schema-version.ts`).
1474
+ await refuseIncompatibleControlStore(this.db!, this.config.partyId);
1475
+ await this.applyControlSchema();
1476
+ log('Schema loaded and executed');
1477
+ }
1478
+
1479
+ /** Apply the declared control schema, retrying transient cohort failures. */
1480
+ private async applyControlSchema(): Promise<void> {
1083
1481
  // Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
1084
1482
  // retry every other control write does ({@link lockedWithRetry}). Every CadreControl
1085
- // table is optimystic-backed (the default vtab was routed to optimystic just above),
1483
+ // table is optimystic-backed (`initialize` routes the default vtab to optimystic),
1086
1484
  // so each `create table` needs a super-majority of the party's peers to answer. On a
1087
1485
  // node joining a party that already has two live members a single unanswered peer
1088
1486
  // during any one create otherwise kills startup outright.
1089
1487
  //
1090
- // Re-running the WHOLE `exec` after a partial failure is safe, and this is the
1488
+ // Re-running the apply after a partial failure is safe, and this is the
1091
1489
  // non-obvious part:
1092
1490
  //
1093
1491
  // - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
@@ -1095,11 +1493,6 @@ export class ControlDatabase {
1095
1493
  // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
1096
1494
  // A table the live catalog already lists generates no statements at all, which is what
1097
1495
  // 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
1496
  // - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
1104
1497
  // undo journal; when a step fails it runs that journal in reverse, re-renders the
1105
1498
  // catalog, compares it against a fingerprint taken BEFORE the apply, and only then
@@ -1121,6 +1514,20 @@ export class ControlDatabase {
1121
1514
  // argument is a statement about the pair, not about Quereus alone — pinned by
1122
1515
  // `test/control-schema-apply-unwind.spec.ts`.
1123
1516
  //
1517
+ // The same argument covers the statements a MIGRATION runs (`control-schema-version.ts`'s
1518
+ // `catalog-only` kinds, from Optimystic 1.16.0): the plugin's `alterTable` arms (add column,
1519
+ // drop column, add and drop CHECK) and the tag change write the table's catalog record under
1520
+ // the batch's per-statement checkpoint, so a step that throws withdraws its own write, and a
1521
+ // batch whose commit fails is discarded whole and the tables it wrote re-persist on their next
1522
+ // touch. A change the record already holds — another machine migrated first — is adopted with
1523
+ // nothing written, and an undo of an adopted change touches this machine's catalog only. The
1524
+ // one arm Quereus marks irreversible is `drop column`: once it has run, a later failure
1525
+ // unwinds nothing (the apply reports the schema as partially migrated), the plugin's
1526
+ // `endSchemaBatch` commits the steps that landed — or, when that commit fails, marks their
1527
+ // tables to re-persist on next touch — and the retry's diff runs only the steps still
1528
+ // missing. No row is lost either way: rows keep a dropped column's field until they are
1529
+ // rewritten, and nothing reads it.
1530
+ //
1124
1531
  // Only the "cohort did not answer, nothing committed" class is retried; the classifier
1125
1532
  // vetoes indeterminate commits and does not match `Missing block`, which is a durable
1126
1533
  // convergence fault a retry cannot heal (tracked separately) and must keep propagating.
@@ -1132,8 +1539,7 @@ export class ControlDatabase {
1132
1539
  // also what lets schema init absorb optimystic's self-coordination grace refusal, which
1133
1540
  // is unsafe for control writes in general. Why, on
1134
1541
  // `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');
1542
+ await this.lockedWithRetry(() => this.db!.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
1137
1543
  }
1138
1544
 
1139
1545
  /**
@@ -1213,9 +1619,10 @@ export class ControlDatabase {
1213
1619
  * behind back-to-back local writes, narrow the count to the span from a body's first
1214
1620
  * statement to its transaction opening.
1215
1621
  */
1216
- private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<Record<string, SqlValue>[]> {
1622
+ private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<RoutedRead> {
1623
+ const committed = this.writeInFlight(underWriteLock);
1217
1624
  let iterator: AsyncIterableIterator<Record<string, SqlValue>>;
1218
- if (this.writeInFlight(underWriteLock)) {
1625
+ if (committed) {
1219
1626
  log('read-eval: a write is in flight (%s), asking for a committed read: %s',
1220
1627
  this.db!.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
1221
1628
  iterator = this.db!.eval(sql, params, { readConcurrency: 'committed' });
@@ -1226,7 +1633,7 @@ export class ControlDatabase {
1226
1633
  for await (const row of iterator) {
1227
1634
  rows.push(row);
1228
1635
  }
1229
- return rows;
1636
+ return { rows, committed };
1230
1637
  }
1231
1638
 
1232
1639
  /**
@@ -1286,12 +1693,26 @@ export class ControlDatabase {
1286
1693
  * held, and its membership read is opted out at {@link queryCadrePeers}. If another
1287
1694
  * under-lock callback seam is ever added, audit its read graph the same way.
1288
1695
  */
1289
- private readRows(
1696
+ private async readRows(
1290
1697
  sql: string,
1291
1698
  params: SqlParameters | undefined,
1292
1699
  label: string,
1293
1700
  retry = true
1294
1701
  ): Promise<Record<string, SqlValue>[]> {
1702
+ return (await this.readRowsRouted(sql, params, label, retry)).rows;
1703
+ }
1704
+
1705
+ /**
1706
+ * {@link readRows}, also saying whether the attempt that answered took the committed-read
1707
+ * path — which a {@link MembershipView} load needs, since it may not keep such rows (see
1708
+ * {@link membershipView}).
1709
+ */
1710
+ private readRowsRouted(
1711
+ sql: string,
1712
+ params: SqlParameters | undefined,
1713
+ label: string,
1714
+ retry: boolean
1715
+ ): Promise<RoutedRead> {
1295
1716
  // `retry: false` marks the reads that may run inside a locked write body, which is
1296
1717
  // also what readRowsOnce's committed-read routing must know (see writeInFlight).
1297
1718
  // NOTE: the membership-gate refresh passes it on EVERY trigger, including the
@@ -1310,21 +1731,53 @@ export class ControlDatabase {
1310
1731
  );
1311
1732
  }
1312
1733
 
1734
+ /**
1735
+ * A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
1736
+ * funnel. Rows a committed read served are never kept: that read may initialize an untouched
1737
+ * table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
1738
+ * on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
1739
+ * event would ever drop them.
1740
+ */
1741
+ private membershipView(sql: string, label: string): MembershipView<Record<string, SqlValue>> {
1742
+ return new MembershipView(async (retry) => {
1743
+ const { rows, committed } = await this.readRowsRouted(sql, undefined, label, retry);
1744
+ return { rows, installable: !committed };
1745
+ });
1746
+ }
1747
+
1748
+ /**
1749
+ * A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
1750
+ * and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
1751
+ * `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
1752
+ * membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
1753
+ * Callers build fresh result objects from them and never mutate them.
1754
+ *
1755
+ * Reads the table directly instead when a caller under the write lock (`retry: false`) has an
1756
+ * explicit transaction open: such a read sees the transaction's own uncommitted rows, which
1757
+ * the view does not hold, and a rollback fires no change event, so keeping them would cache
1758
+ * rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
1759
+ * this keeps one added later, or a spec's raw-handle transaction, correct.
1760
+ *
1761
+ * Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
1762
+ * schema's CHECK constraints, which read inside the committing transaction, the redemption's
1763
+ * one read of the held credential row ({@link queryCadreInvite},
1764
+ * {@link queryOwnerGrant}), and the indexed usage counts.
1765
+ */
1766
+ private readMembershipRows(view: MembershipView<Record<string, SqlValue>>, retry: boolean): Promise<readonly Record<string, SqlValue>[]> {
1767
+ this.ensureInitialized();
1768
+ if (!retry && !this.db!.getAutocommit()) {
1769
+ return view.readThrough(retry);
1770
+ }
1771
+ return view.read(retry);
1772
+ }
1773
+
1313
1774
  /**
1314
1775
  * Query all strands from the control database
1315
1776
  */
1316
1777
  async queryStrands(): Promise<StrandRow[]> {
1317
1778
  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;
1779
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
1780
+ return rows.map(strandRow);
1328
1781
  }
1329
1782
 
1330
1783
  /**
@@ -1335,25 +1788,26 @@ export class ControlDatabase {
1335
1788
  */
1336
1789
  async queryStrand(strandId: string): Promise<StrandRow | null> {
1337
1790
  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;
1791
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
1792
+ return rows.length === 0 ? null : strandRow(rows[0]!);
1793
+ }
1794
+
1795
+ /**
1796
+ * {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
1797
+ * resolves a bound invite's host strand with it and later records consent against that
1798
+ * stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
1799
+ */
1800
+ async queryStrandWithStamp(strandId: string): Promise<(StrandRow & { StampId: string }) | null> {
1801
+ this.ensureInitialized();
1802
+ const rows = await this.readRows(
1803
+ `select ${STRAND_COLUMNS}, StampId from CadreControl.Strand where Id = ?`, [strandId], 'strand-with-stamp');
1804
+ return rows.length === 0 ? null : { ...strandRow(rows[0]!), StampId: rows[0]!.StampId as string };
1351
1805
  }
1352
1806
 
1353
1807
  /**
1354
1808
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
1355
1809
  * 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}:
1810
+ * never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
1357
1811
  * no retired-stamp filter.
1358
1812
  */
1359
1813
  async queryJoinedStrands(): Promise<StrandRow[]> {
@@ -1439,31 +1893,75 @@ export class ControlDatabase {
1439
1893
  *
1440
1894
  * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1441
1895
  * insert is refused by the schema whenever any physical row exists, so this must see one.
1896
+ * Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
1897
+ * physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
1898
+ * one storage read.
1442
1899
  */
1443
1900
  async hasOwnerKey(): Promise<boolean> {
1444
1901
  this.ensureInitialized();
1445
- for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
1446
- return (row.Count as number) > 0;
1447
- }
1448
- return false;
1902
+ return (await this.readMembershipRows(this.ownerKeyView, true)).length > 0;
1449
1903
  }
1450
1904
 
1451
1905
  /**
1452
1906
  * Idempotent genesis: insert `key` as the founding owner key only when
1453
1907
  * the party has none yet. Returns true if it inserted, false if an owner
1454
1908
  * key already existed (so a repeat `--owner` start is a no-op).
1909
+ *
1910
+ * Before the insert, gives every control table its empty collection
1911
+ * ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
1912
+ * forks if another machine creates the same collection independently; before genesis the
1913
+ * party has no owner, so no other machine can have been admitted and none can hold any of
1914
+ * its collections. The order makes the step repeatable: a crash between it and the insert
1915
+ * leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
1916
+ * collections still missing, while a founder restart finds the row and commits nothing, so
1917
+ * it can never write an empty second history beside a table another machine has since
1918
+ * written. A failure propagates with nothing inserted.
1919
+ *
1920
+ * @param label - the user's name for the key ({@link insertOwnerKey}).
1455
1921
  */
1456
- async ensureOwnerKey(key: string): Promise<boolean> {
1922
+ async ensureOwnerKey(key: string, label?: string): Promise<boolean> {
1457
1923
  this.ensureInitialized();
1458
1924
  const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
1459
1925
  if (await this.hasOwnerKey()) {
1460
1926
  log('Owner key already present; skipping genesis insert');
1461
1927
  return false;
1462
1928
  }
1463
- await this.insertOwnerKey(trimmed);
1929
+ // Two locked steps one after the other: the lock is not re-entrant, and the insert takes it itself.
1930
+ await this.commitEmptyControlCollections();
1931
+ await this.insertOwnerKey(trimmed, label);
1464
1932
  return true;
1465
1933
  }
1466
1934
 
1935
+ /**
1936
+ * Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
1937
+ * tree that has none, so a read of a table nobody has written is answered from this machine's
1938
+ * storage instead of asking the block's cohort to prove the collection absent — a round trip
1939
+ * per read on a party of several machines, and an error or a wait while they are offline.
1940
+ * Joining machines get these collections the ordinary way, by reading them from the cohort.
1941
+ *
1942
+ * Under the write lock: the helper commits whatever is staged in those collections outside any
1943
+ * SQL transaction, which is sound only while nothing else writes.
1944
+ *
1945
+ * Parties founded before this step, and tables a later control schema version adds, have no
1946
+ * such collections, and nothing creates them later: the start-time schema apply runs on every
1947
+ * machine, possibly alone, where committing one would be exactly the fork genesis avoids.
1948
+ *
1949
+ * NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
1950
+ * 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
1951
+ * transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
1952
+ * 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
1953
+ * node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
1954
+ * geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
1955
+ * (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
1956
+ * `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
1957
+ * founding would use too; do not build a private batching path.
1958
+ */
1959
+ private async commitEmptyControlCollections(): Promise<void> {
1960
+ const t0 = performance.now();
1961
+ const committed = await this.withWriteLock(() => createEmptyCollections(this.db!, this.txnBridge!, [CONTROL_SCHEMA_NAME]));
1962
+ log('Committed %d empty control collections in %dms', committed, Math.round(performance.now() - t0));
1963
+ }
1964
+
1467
1965
  /**
1468
1966
  * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
1469
1967
  *
@@ -1491,8 +1989,9 @@ export class ControlDatabase {
1491
1989
  }
1492
1990
 
1493
1991
  /**
1494
- * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1495
- * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1992
+ * Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
1993
+ * the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
1994
+ * listing shows (`CadreNode.listOwnerKeys`). Rows whose
1496
1995
  * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1497
1996
  * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1498
1997
  * reads through this. A row with no stamp (the schema permits it; no writer produces one)
@@ -1504,16 +2003,14 @@ export class ControlDatabase {
1504
2003
  *
1505
2004
  * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1506
2005
  * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1507
- * signers); omitted, they are read here. Before the ledger marker is filed every
1508
- * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1509
- * membership refresh at one such read for the anchor.
2006
+ * signers); omitted, they are read here. Both come from the same `Revocation` view
2007
+ * ({@link readMembershipRows}), so passing the set saves only the filtering.
1510
2008
  */
1511
2009
  async queryOwnerKeyRows(retry = true, retired?: ReadonlySet<string>): Promise<OwnerKeyRow[]> {
1512
2010
  this.ensureInitialized();
1513
2011
  const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1514
2012
  const rows: OwnerKeyRow[] = [];
1515
- const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1516
- for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
2013
+ for (const row of await this.readMembershipRows(this.ownerKeyView, retry)) {
1517
2014
  const stampId = (row.StampId as string | null) ?? null;
1518
2015
  if (stampId === null) {
1519
2016
  log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
@@ -1524,6 +2021,7 @@ export class ControlDatabase {
1524
2021
  }
1525
2022
  rows.push({
1526
2023
  key: row.Key as string,
2024
+ label: (row.Label as string | null) ?? null,
1527
2025
  stampId,
1528
2026
  vouchOwner: (row.VouchOwner as string | null) ?? null,
1529
2027
  vouchSig: (row.VouchSig as string | null) ?? null,
@@ -1545,12 +2043,8 @@ export class ControlDatabase {
1545
2043
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
1546
2044
  * that seam to model the replicated live-row-plus-tombstone merge state.
1547
2045
  *
1548
- * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
1549
- * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
1550
- * while `Revocation` has never been written every call consults the cohort about it, and
1551
- * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
1552
- * the exclusion into one statement would not help (it reads the same block), and caching
1553
- * the set would hide a tombstone arriving by replication.
2046
+ * Both tables come from their membership views ({@link readMembershipRows}), so a call after
2047
+ * the first costs no storage read until one of the two tables changes.
1554
2048
  *
1555
2049
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
1556
2050
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -1573,43 +2067,28 @@ export class ControlDatabase {
1573
2067
  this.ensureInitialized();
1574
2068
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1575
2069
  const rows: CadrePeerRow[] = [];
1576
- // The proof columns lead and the record columns follow, so this scan keeps a prefix no
1577
- // other `CadrePeer` statement shares (`queryPeerRecord` starts `PeerId, PublicKey`): the
1578
- // read-retry spec rigs the membership scan by that prefix.
1579
- const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
1580
- for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
1581
- const stampId = (row.StampId as string | null) ?? null;
1582
- if (stampId !== null && revoked.has(stampId)) {
2070
+ for (const row of await this.readMembershipRows(this.cadrePeerView, retry)) {
2071
+ const peer = cadrePeerRowOf(row);
2072
+ if (peer.stampId !== null && revoked.has(peer.stampId)) {
1583
2073
  continue;
1584
2074
  }
1585
- rows.push({
1586
- peerId: row.PeerId as string,
1587
- publicKey: (row.PublicKey as string | null) ?? null,
1588
- multiaddr: (row.Multiaddr as string | null) ?? null,
1589
- updatedAt: (row.UpdatedAt as number | null) ?? 0,
1590
- profile: (row.Profile as NodeProfile | null) ?? null,
1591
- sig: (row.Sig as string | null) ?? null,
1592
- stampId,
1593
- vouchOwner: (row.VouchOwner as string | null) ?? null,
1594
- vouchSig: (row.VouchSig as string | null) ?? null,
1595
- vouchUsage: (row.VouchUsage as string | null) ?? null,
1596
- });
2075
+ rows.push(peer);
1597
2076
  }
1598
2077
  return rows;
1599
2078
  }
1600
2079
 
1601
2080
  /**
1602
2081
  * 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}).
2082
+ * row with its addresses, the minter and every always-on machine marked preferred
2083
+ * ({@link projectSeedPeers}).
1605
2084
  *
1606
2085
  * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1607
2086
  * member's addresses are never packed into a seed or handed to a redeeming device as a
1608
2087
  * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1609
2088
  * its exits.
1610
2089
  */
1611
- async querySeedPeers(): Promise<SeedPeer[]> {
1612
- return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
2090
+ async querySeedPeers(minterPeerId: string): Promise<SeedPeer[]> {
2091
+ return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
1613
2092
  }
1614
2093
 
1615
2094
  /**
@@ -1650,10 +2129,11 @@ export class ControlDatabase {
1650
2129
  * guard. A read retrying there would sleep its backoff HOLDING the write lock
1651
2130
  * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
1652
2131
  * the write funnel already re-runs the body's reads when it re-runs the body. The
1653
- * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
1654
- * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
1655
- * default retried path. Explicit per call rather than an ambient flag: unlocked reads
1656
- * run concurrently with a locked body, so `this`-state cannot tell the two apart.
2132
+ * unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
2133
+ * read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
2134
+ * rather than an ambient flag: unlocked reads run concurrently with a locked body, so
2135
+ * `this`-state cannot tell the two apart. The same flag threads through
2136
+ * {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
1657
2137
  */
1658
2138
  private async queryStampId(
1659
2139
  table: RevocableTable,
@@ -1671,8 +2151,8 @@ export class ControlDatabase {
1671
2151
  /**
1672
2152
  * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
1673
2153
  * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
1674
- * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
1675
- * straight to {@link queryStampId}.
2154
+ * `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
2155
+ * the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
1676
2156
  */
1677
2157
  queryCadrePeerStampId(peerId: string): Promise<string | null> {
1678
2158
  return this.queryStampId('CadrePeer', peerId);
@@ -1738,18 +2218,17 @@ export class ControlDatabase {
1738
2218
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
1739
2219
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
1740
2220
  *
1741
- * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
1742
- * `Revocation` has never been written, this node does not hold its block, and Optimystic
1743
- * consults a block's cohort on every read of a block it does not hold: 1 consult per
1744
- * call (measured), each a round trip to every other member on a multi-machine party. The
1745
- * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
1746
- * at most once per read-repair window (10 s). Do not cache the set instead: a cache
1747
- * cannot see a tombstone arriving by replication, so it would delay a revocation. The
1748
- * row count only grows (append-only) but stays small while removals are rare; revisit if
2221
+ * Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
2222
+ * every change to the table, local or replicated into this node's storage, so a tombstone
2223
+ * arriving by replication is seen on the next call. The cost that matters is the storage
2224
+ * layer's, paid by a cold view's load: while `Revocation` has never been written, this node
2225
+ * does not hold its block, and Optimystic consults a block's cohort on every read of a block
2226
+ * it does not hold, each a round trip to every other member on a multi-machine party. The row
2227
+ * count only grows (append-only) but stays small while removals are rare; revisit if
1749
2228
  * removals become routine and the scan itself shows up in a profile.
1750
2229
  *
1751
2230
  * An isolated node that has never received the `Revocation` block reads it as holding no
1752
- * revocations instead of throwing — see {@link readRevokedStampRows}.
2231
+ * revocations instead of throwing — see {@link readRevocationRowsOrNone}.
1753
2232
  *
1754
2233
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
1755
2234
  * under the write lock — see that method's note.
@@ -1757,22 +2236,26 @@ export class ControlDatabase {
1757
2236
  async queryRevokedStamps(tableName: RevocableTable, retry = true): Promise<Set<string>> {
1758
2237
  this.ensureInitialized();
1759
2238
  const stamps = new Set<string>();
1760
- for (const row of await this.readRevokedStampRows(tableName, retry)) {
1761
- stamps.add(row.StampId as string);
2239
+ for (const row of await this.readRevocationRowsOrNone(retry)) {
2240
+ if (row.TableName === tableName) {
2241
+ stamps.add(row.StampId as string);
2242
+ }
1762
2243
  }
1763
2244
  return stamps;
1764
2245
  }
1765
2246
 
1766
2247
  /**
1767
- * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
1768
- * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
2248
+ * The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
2249
+ * load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1769
2250
  * the block and could ask no other cohort member about it. Every other failure rethrows.
1770
2251
  * The retry in {@link readRows} runs first, so a node whose connectivity returns within
1771
- * the read budget still gets the real answer.
2252
+ * the read budget still gets the real answer. The empty answer is never installed in the
2253
+ * view (a failed load keeps nothing), so the next call asks again.
1772
2254
  *
1773
2255
  * Without this, a machine cut off from every other machine in its party before it ever
1774
- * received the block — the block does not exist anywhere until the owner files the
1775
- * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
2256
+ * received the block — a joining machine gets it only by reading it from the cohort, and
2257
+ * on a party founded before genesis created every control collection it exists nowhere
2258
+ * until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
1776
2259
  * answer ANY membership, peer-record or device-token lookup, although it holds those
1777
2260
  * rows locally.
1778
2261
  *
@@ -1791,19 +2274,19 @@ export class ControlDatabase {
1791
2274
  *
1792
2275
  * NOTE: an isolated node pays the whole read retry (up to
1793
2276
  * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
1794
- * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
1795
- * node's lookups are seen exceeding the admission gate's 2 s deadline
1796
- * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
1797
- * fails `cohort-unreachable`.
2277
+ * answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
2278
+ * load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
2279
+ * admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
2280
+ * read when the first attempt fails `cohort-unreachable`.
1798
2281
  */
1799
- private async readRevokedStampRows(tableName: RevocableTable, retry: boolean): Promise<Record<string, SqlValue>[]> {
2282
+ private async readRevocationRowsOrNone(retry: boolean): Promise<readonly Record<string, SqlValue>[]> {
1800
2283
  try {
1801
- return await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry);
2284
+ return await this.readMembershipRows(this.revocationView, retry);
1802
2285
  } catch (error) {
1803
2286
  if (!isCohortUnreachableRead(error)) {
1804
2287
  throw error;
1805
2288
  }
1806
- log('revoked-stamps(%s): no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', tableName, error);
2289
+ log('revoked-stamps: no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', error);
1807
2290
  return [];
1808
2291
  }
1809
2292
  }
@@ -1816,10 +2299,10 @@ export class ControlDatabase {
1816
2299
  * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1817
2300
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1818
2301
  *
1819
- * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1820
- * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1821
- * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1822
- * admits no other row under `'Revocation'`.
2302
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
2303
+ * retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
2304
+ * `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
2305
+ * TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
1823
2306
  *
1824
2307
  * `retry: false` from a caller that reads under the write lock, for the reason on
1825
2308
  * {@link queryCadrePeers}.
@@ -1827,25 +2310,17 @@ export class ControlDatabase {
1827
2310
  async queryRevocations(retry = true): Promise<RevocationRow[]> {
1828
2311
  this.ensureInitialized();
1829
2312
  const rows: RevocationRow[] = [];
1830
- const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
1831
- for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
1832
- if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1833
- continue;
2313
+ for (const row of await this.readMembershipRows(this.revocationView, retry)) {
2314
+ if (row.TableName !== REVOCATION_LEDGER_MARKER.tableName) {
2315
+ rows.push(revocationRowOf(row));
1834
2316
  }
1835
- rows.push({
1836
- tableName: row.TableName as RevocableTable,
1837
- rowKey: row.RowKey as string,
1838
- stampId: row.StampId as string,
1839
- reissuedAt: (row.ReissuedAt as number | null) ?? 0,
1840
- signerKey: row.SignerKey as string,
1841
- signerSig: row.SignerSig as string,
1842
- });
1843
2317
  }
1844
2318
  return rows;
1845
2319
  }
1846
2320
 
1847
2321
  /**
1848
- * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
2322
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
2323
+ * `CadrePeer` membership view ({@link readMembershipRows}).
1849
2324
  *
1850
2325
  * Returns null when no row exists, or when the row's `StampId` is retired in
1851
2326
  * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
@@ -1861,34 +2336,36 @@ export class ControlDatabase {
1861
2336
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
1862
2337
  * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
1863
2338
  * the publisher signed.
2339
+ *
2340
+ * `retry: false` from the one caller that reads under the write lock,
2341
+ * {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
2342
+ */
2343
+ async queryPeerRecord(peerId: string, retry = true): Promise<PeerAddressRecord | null> {
2344
+ const row = await this.findCadrePeerRow(peerId, retry);
2345
+ return row === null ? null : peerAddressRecordOf(row);
2346
+ }
2347
+
2348
+ /**
2349
+ * One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
2350
+ * or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
2351
+ * and the two writers that decide over the row inside their locked body
2352
+ * ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
2353
+ * {@link queryCadrePeers}, and the same `retry` contract.
1864
2354
  */
1865
- async queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null> {
2355
+ private async findCadrePeerRow(peerId: string, retry: boolean): Promise<CadrePeerRow | null> {
1866
2356
  this.ensureInitialized();
1867
- const revoked = await this.queryRevokedStamps('CadrePeer');
1868
- for (const row of await this.readRows(
1869
- 'select PeerId, PublicKey, Multiaddr, UpdatedAt, Profile, Sig, StampId from CadreControl.CadrePeer where PeerId = ?',
1870
- [peerId],
1871
- 'peer-record'
1872
- )) {
1873
- const stampId = (row.StampId as string | null) ?? null;
1874
- if (stampId !== null && revoked.has(stampId)) {
1875
- return null;
1876
- }
1877
- const multiaddr = (row.Multiaddr as string | null) ?? '';
1878
- return {
1879
- peerId: row.PeerId as string,
1880
- publicKey: (row.PublicKey as string | null) ?? '',
1881
- addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
1882
- updatedAt: (row.UpdatedAt as number | null) ?? 0,
1883
- profile: (row.Profile as NodeProfile | null) ?? null,
1884
- sig: (row.Sig as string | null) ?? '',
1885
- };
2357
+ const revoked = await this.queryRevokedStamps('CadrePeer', retry);
2358
+ const row = (await this.readMembershipRows(this.cadrePeerView, retry)).find((candidate) => candidate.PeerId === peerId);
2359
+ if (row === undefined) {
2360
+ return null;
1886
2361
  }
1887
- return null;
2362
+ const peer = cadrePeerRowOf(row);
2363
+ return peer.stampId !== null && revoked.has(peer.stampId) ? null : peer;
1888
2364
  }
1889
2365
 
1890
2366
  /**
1891
- * Apply a peer's own self-signed address-record update to an existing row.
2367
+ * Apply a peer's own self-signed address-record update to its existing row: read the row
2368
+ * under the write lock, have `sign` build the record over it, and write that record.
1892
2369
  *
1893
2370
  * Authorization is carried entirely by the record: the `Sig` column (verified
1894
2371
  * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
@@ -1897,6 +2374,21 @@ export class ControlDatabase {
1897
2374
  * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
1898
2375
  * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
1899
2376
  *
2377
+ * The read, the decision and the signature all run INSIDE the locked body, which is what
2378
+ * lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
2379
+ * re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
2380
+ * When this write loses that race the commit is refused as a lost update and re-run, and the
2381
+ * re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
2382
+ * signed before the race could no longer exceed — and signs past it. A record signed outside
2383
+ * the body would be re-presented unchanged and refused again, this time by the schema's
2384
+ * monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
2385
+ * (see {@link queryStampId}'s NOTE).
2386
+ *
2387
+ * Returns the record actually written, so the caller's bookkeeping describes the row rather
2388
+ * than the first record it signed; null, and nothing written, when no readable row exists:
2389
+ * the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
2390
+ * as absent every heartbeat, and must not refresh it).
2391
+ *
1900
2392
  * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
1901
2393
  * is not: it only ever touches THIS node's own row (sole caller
1902
2394
  * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
@@ -1909,17 +2401,42 @@ export class ControlDatabase {
1909
2401
  * row, and a self insert happens once at startup, so the wasted refresh is a single read
1910
2402
  * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
1911
2403
  * path is worth exempting.
2404
+ *
2405
+ * @param peerId - the row to refresh: this node's own.
2406
+ * @param sign - builds the signed record over the row as read; called once per attempt.
1912
2407
  */
1913
- async updateSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
2408
+ async updateSelfPeerRecord(peerId: string, sign: SelfRecordSigner): Promise<PeerAddressRecord | null> {
1914
2409
  this.ensureInitialized();
1915
- const multiaddr = record.addrs.join(',');
1916
- await this.execWrite(`
1917
- update CadreControl.CadrePeer
1918
- with context OwnerKey = null, Signature = ?
1919
- set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
1920
- where PeerId = ?
1921
- `, [record.sig, multiaddr, record.updatedAt, record.profile, record.sig, record.peerId], 'self-record-update');
1922
- log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
2410
+ const written = await this.lockedWithRetry(async () => {
2411
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2412
+ // NOTE: this read of the Revocation and CadrePeer views is charged to the retry budget,
2413
+ // so a cold view load that waits out a per-peer read deadline leaves the attempt no room
2414
+ // for its one retry (the 14.7 s abandonment the policy's doc cites). The caller's read
2415
+ // outside the funnel warms the views, so this is a cache hit except when a table change
2416
+ // between the two reads dropped them — the racing case itself. If that residual ever
2417
+ // matters, measure which phase of the losing attempt pays the deadline first; the
2418
+ // retired-stamp check cannot simply move outside, since `publishSelfRecord`'s insert
2419
+ // path reads a revoked node's row as absent and relies on this body to skip it.
2420
+ const current = await this.queryPeerRecord(peerId, false);
2421
+ if (current === null) {
2422
+ return null;
2423
+ }
2424
+ const record = sign(current);
2425
+ // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2426
+ await this.db!.exec(`
2427
+ update CadreControl.CadrePeer
2428
+ with context OwnerKey = null, Signature = ?
2429
+ set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
2430
+ where PeerId = ?
2431
+ `, [record.sig, record.addrs.join(','), record.updatedAt, record.profile, record.sig, peerId]);
2432
+ return record;
2433
+ }, LOST_UPDATE_RETRY_POLICY, 'self-record-update');
2434
+ if (written === null) {
2435
+ log('Self peer record not updated: no readable CadrePeer row for %s (absent, or its stamp retired)', peerId);
2436
+ } else {
2437
+ log('Self peer record updated: %s (updatedAt=%d)', peerId, written.updatedAt);
2438
+ }
2439
+ return written;
1923
2440
  }
1924
2441
 
1925
2442
  /**
@@ -1978,9 +2495,13 @@ export class ControlDatabase {
1978
2495
 
1979
2496
  /**
1980
2497
  * Insert the initial owner key (bootstrap - no existing owners required)
2498
+ *
2499
+ * @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
2500
+ * characters once trimmed; blank is stored as none.
1981
2501
  */
1982
- async insertOwnerKey(key: string): Promise<void> {
2502
+ async insertOwnerKey(key: string, label?: string): Promise<void> {
1983
2503
  this.ensureInitialized();
2504
+ const storedLabel = ownerKeyLabel(label);
1984
2505
  log('Inserting owner key: %s', key);
1985
2506
 
1986
2507
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
@@ -1993,10 +2514,10 @@ export class ControlDatabase {
1993
2514
  // optimystic `StampId()` SQL function.
1994
2515
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1995
2516
  await this.execWrite(`
1996
- insert into CadreControl.OwnerKey (Key, StampId)
2517
+ insert into CadreControl.OwnerKey (Key, Label, StampId)
1997
2518
  with context OwnerKey = null, Signature = null
1998
- values (?, ?)
1999
- `, [key, stampId], 'owner-key-insert');
2519
+ values (?, ?, ?)
2520
+ `, [key, storedLabel, stampId], 'owner-key-insert');
2000
2521
  log('Owner key inserted');
2001
2522
  }
2002
2523
 
@@ -2004,57 +2525,85 @@ export class ControlDatabase {
2004
2525
  * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
2005
2526
  * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
2006
2527
  * 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`).
2528
+ * (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
2529
+ * reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
2009
2530
  *
2010
2531
  * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
2011
2532
  * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
2012
2533
  * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
2013
2534
  *
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
- *
2535
+ * @param label - the user's name for the key, as {@link insertOwnerKey}'s.
2021
2536
  * @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.
2537
+ * here (nothing written, whatever its label).
2538
+ * @throws when this node holds `key`'s row under a retired stamp
2539
+ * ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
2025
2540
  */
2026
2541
  async insertOwnerKeyVouched(
2027
2542
  key: string,
2028
2543
  ownerKey: string,
2029
- signMessage: (message: Uint8Array) => string
2544
+ signMessage: ControlSign,
2545
+ label?: string | null
2030
2546
  ): Promise<boolean> {
2031
2547
  this.ensureInitialized();
2548
+ const storedLabel = ownerKeyLabel(label);
2032
2549
  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
- );
2550
+ if (await this.liveOwnerKeyStamp(key) !== null) {
2551
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
2552
+ return false;
2044
2553
  }
2045
2554
  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]);
2555
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
2556
+ await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
2053
2557
  log('Owner key %s added (vouched by %s)', key, ownerKey);
2054
2558
  return true;
2055
2559
  });
2056
2560
  }
2057
2561
 
2562
+ /**
2563
+ * The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
2564
+ * insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
2565
+ * the locked body (both reads `retry: false`; see queryStampId's NOTE).
2566
+ *
2567
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
2568
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
2569
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
2570
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
2571
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
2572
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
2573
+ *
2574
+ * @throws when this node holds `key`'s row under a retired stamp (above).
2575
+ */
2576
+ private async liveOwnerKeyStamp(key: string): Promise<string | null> {
2577
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
2578
+ if (heldStamp === null) {
2579
+ return null;
2580
+ }
2581
+ if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
2582
+ throw new Error(
2583
+ `Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
2584
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it'
2585
+ );
2586
+ }
2587
+ return heldStamp;
2588
+ }
2589
+
2590
+ /**
2591
+ * The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
2592
+ * stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
2593
+ * NOT re-entrant.
2594
+ */
2595
+ private async execOwnerKeyVouchedInsert(
2596
+ row: { key: string; label: string | null; stampId: string },
2597
+ ownerKey: string,
2598
+ signature: string
2599
+ ): Promise<void> {
2600
+ await this.db!.exec(`
2601
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
2602
+ with context OwnerKey = ?, Signature = ?
2603
+ values (?, ?, ?, ?, ?)
2604
+ `, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
2605
+ }
2606
+
2058
2607
  /**
2059
2608
  * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
2060
2609
  * stamp, notifying the membership listener once it has committed. The remove branch of
@@ -2075,7 +2624,7 @@ export class ControlDatabase {
2075
2624
  async deleteOwnerKey(
2076
2625
  key: string,
2077
2626
  ownerKey: string,
2078
- signMessage: (message: Uint8Array) => string
2627
+ signMessage: ControlSign
2079
2628
  ): Promise<boolean> {
2080
2629
  if (key === ownerKey) {
2081
2630
  throw new Error(`Cannot remove owner key ${key}: an owner cannot remove itself`);
@@ -2097,14 +2646,14 @@ export class ControlDatabase {
2097
2646
  /**
2098
2647
  * Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
2099
2648
  * 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
2649
+ * of the same key and label under a fresh stamp, signed by `ownerKey`
2650
+ * ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
2102
2651
  * this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
2103
2652
  * 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.
2653
+ * `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
2654
+ * everywhere once their row stands on a remaining owner's signature.
2106
2655
  *
2107
- * Through {@link mutateCadrePeer}, the membership hub. The stamp read runs inside the locked
2656
+ * Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
2108
2657
  * body; a row whose stamp is already retired (held without its delete) is reported as absent
2109
2658
  * rather than tombstoned twice.
2110
2659
  *
@@ -2114,70 +2663,66 @@ export class ControlDatabase {
2114
2663
  async revouchOwnerKey(
2115
2664
  key: string,
2116
2665
  ownerKey: string,
2117
- signMessage: (message: Uint8Array) => string
2666
+ signMessage: ControlSign
2118
2667
  ): Promise<boolean> {
2119
2668
  if (key === ownerKey) {
2120
2669
  throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
2121
2670
  }
2122
2671
  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)) {
2672
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2673
+ const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
2674
+ if (live === undefined) {
2126
2675
  log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
2127
2676
  return false;
2128
2677
  }
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));
2678
+ const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
2679
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2680
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
2132
2681
  await this.inTransaction('revouch OwnerKey', async () => {
2133
2682
  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]);
2683
+ await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
2139
2684
  });
2140
- log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
2685
+ log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
2141
2686
  this.notifyGuardedDelete(removal.ref);
2142
2687
  return true;
2143
2688
  });
2144
2689
  }
2145
2690
 
2146
2691
  /**
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.
2692
+ * Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
2693
+ * `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
2694
+ * ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
2695
+ * composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
2696
+ * that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
2697
+ * sees the machine half removed: a row gone but its push token resolvable, or an invitation
2698
+ * it could redeem again still open. Owner keys are never touched: they are the person's, and
2699
+ * removing one is {@link deleteOwnerKey}.
2155
2700
  *
2156
2701
  * Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
2157
2702
  * 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.
2703
+ * than tombstoned twice. Nothing to remove writes nothing and returns all false.
2160
2704
  *
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.
2705
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
2706
+ * stamp and signature is minted inside the body, and the invitation keys come from the
2707
+ * caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
2708
+ * since collides on `Revocation`'s primary key — a constraint failure, never retried, as
2709
+ * before). A lost update after a sibling tree of the transaction already synced arrives as a
2710
+ * partial commit and stays declined.
2164
2711
  *
2165
- * @throws when `target.ownerKey` is `ownerKey`, or is the only live owner here.
2712
+ * The caller decides which invitations the machine could use, from its plan
2713
+ * (`CadreNode.planMachineRemoval`).
2166
2714
  */
2167
2715
  async removeMachine(
2168
- target: { peerId: string; ownerKey: string | null; inviteKeys: readonly string[] },
2716
+ target: { peerId: string; inviteKeys: readonly string[] },
2169
2717
  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
- }
2718
+ signMessage: ControlSign
2719
+ ): Promise<MachineRemovalResult> {
2175
2720
  return await this.mutateCadrePeer('machine-remove', async () => {
2176
- const removals = await this.signMachineRemovals(target, signMessage);
2177
- const withdrawals = await this.signCadreInviteWithdrawals(target.inviteKeys, signMessage);
2721
+ const removals = await this.signMachineRemovals(target.peerId, signMessage);
2722
+ const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
2178
2723
  if (removals.length === 0 && withdrawals.length === 0) {
2179
2724
  log('removeMachine: nothing live names %s here (already removed)', target.peerId);
2180
- return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [] };
2725
+ return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
2181
2726
  }
2182
2727
  await this.inTransaction('remove machine', async () => {
2183
2728
  for (const removal of removals) {
@@ -2197,23 +2742,19 @@ export class ControlDatabase {
2197
2742
  }
2198
2743
  return {
2199
2744
  removedPeer: removed.has('CadrePeer'),
2200
- removedOwnerKey: removed.has('OwnerKey'),
2201
2745
  removedDeviceToken: removed.has('DeviceToken'),
2202
2746
  withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
2203
2747
  };
2204
- });
2748
+ }, LOST_UPDATE_RETRY_POLICY);
2205
2749
  }
2206
2750
 
2207
2751
  /**
2208
2752
  * 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.
2212
- */
2213
- private async signMachineRemovals(
2214
- target: { peerId: string; ownerKey: string | null },
2215
- signMessage: (message: Uint8Array) => string
2216
- ): Promise<GuardedRemoval[]> {
2753
+ * the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
2754
+ * (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
2755
+ * tables.
2756
+ */
2757
+ private async signMachineRemovals(peerId: string, signMessage: ControlSign): Promise<GuardedRemoval[]> {
2217
2758
  const retiredByTable = new Map<RevocableTable, Set<string>>();
2218
2759
  for (const tombstone of await this.queryRevocations(false)) {
2219
2760
  const stamps = retiredByTable.get(tombstone.tableName) ?? new Set<string>();
@@ -2226,24 +2767,12 @@ export class ControlDatabase {
2226
2767
  return stampId !== null && !retired(table).has(stampId) ? stampId : null;
2227
2768
  };
2228
2769
  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));
2770
+ for (const table of ['CadrePeer', 'DeviceToken'] as const) {
2771
+ const stampId = await liveStamp(table, peerId);
2772
+ if (stampId !== null) {
2773
+ removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
2241
2774
  }
2242
2775
  }
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
2776
  return removals;
2248
2777
  }
2249
2778
 
@@ -2270,32 +2799,86 @@ export class ControlDatabase {
2270
2799
  strandId: string,
2271
2800
  type: 'o' | 'c',
2272
2801
  ownerKey: string,
2273
- signMessage: (message: Uint8Array) => string,
2802
+ signMessage: ControlSign,
2274
2803
  memberPrivateKey?: string
2275
2804
  ): Promise<void> {
2276
2805
  this.ensureInitialized();
2277
2806
  log('Inserting strand: %s (type: %s)', strandId, type);
2807
+ const insert = await this.signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey);
2808
+ await this.execWrite(insert.sql, insert.params, 'strand-insert');
2809
+ log('Strand inserted: %s', strandId);
2810
+ }
2278
2811
 
2812
+ /** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
2813
+ private async signStrandInsert(
2814
+ strandId: string,
2815
+ type: 'o' | 'c',
2816
+ ownerKey: string,
2817
+ signMessage: ControlSign,
2818
+ memberPrivateKey?: string
2819
+ ): Promise<SignedInsert> {
2279
2820
  // Generate a unique stamp ID using the peer ID for distributed uniqueness
2280
2821
  const peerId = this.config.libp2pNode.peerId.toString();
2281
2822
  const stampId = generateStampId(peerId);
2282
2823
 
2283
2824
  // Field order MUST match the schema's Strand `AuthorizedInsert` verify:
2284
- // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
2825
+ // Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
2285
2826
  // deliberately NOT in the digest — the schema binds it by equality to the
2286
2827
  // 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);
2828
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
2829
+ const signature = await signMessage(message);
2289
2830
 
2290
2831
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
2291
- // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
2292
- await this.execWrite(`
2293
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
2294
- with context OwnerKey = ?, Signature = ?
2295
- values (?, ?, ?, ?, ?)
2296
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
2832
+ // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
2833
+ return {
2834
+ sql: `
2835
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2836
+ with context OwnerKey = ?, Signature = ?
2837
+ values (?, ?, ?, ?, ?, ?)
2838
+ `,
2839
+ params: [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId],
2840
+ };
2841
+ }
2297
2842
 
2298
- log('Strand inserted: %s', strandId);
2843
+ /**
2844
+ * Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
2845
+ * row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
2846
+ * never a strand row without the key or invitation written beside it.
2847
+ *
2848
+ * Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
2849
+ * {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
2850
+ * invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
2851
+ * taken: signing may prompt the user, and the lock held across a prompt would stall every
2852
+ * other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
2853
+ *
2854
+ * No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
2855
+ * `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
2856
+ * `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
2857
+ *
2858
+ * A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
2859
+ * again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
2860
+ * stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
2861
+ */
2862
+ async insertFoundingRows(rows: FoundingRows, ownerKey: string, signMessage: ControlSign): Promise<void> {
2863
+ this.ensureInitialized();
2864
+ const { strand, partyKey, formationInvite } = rows;
2865
+ log('Inserting founding rows: strand %s (type: %s, party key: %s, invitation: %s)',
2866
+ strand.id, strand.type, partyKey !== undefined, formationInvite !== undefined);
2867
+ const inserts = [await this.signStrandInsert(strand.id, strand.type, ownerKey, signMessage, strand.memberPrivateKey)];
2868
+ if (partyKey !== undefined) {
2869
+ inserts.push(await this.signStrandPartyKeyInsert(strand.id, partyKey, ownerKey, signMessage));
2870
+ }
2871
+ if (formationInvite) {
2872
+ const { token, sAppId, ...options } = formationInvite;
2873
+ inserts.push(await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, { ...options, strandId: strand.id }));
2874
+ }
2875
+ // Bare `exec`: the lock is held, and `execWrite` would take it again and self-deadlock.
2876
+ await this.lockedWithRetry(() => this.inTransaction('found strand', async () => {
2877
+ for (const insert of inserts) {
2878
+ await this.db!.exec(insert.sql, insert.params);
2879
+ }
2880
+ }), {}, 'strand-found');
2881
+ log('Founding rows inserted: strand %s', strand.id);
2299
2882
  }
2300
2883
 
2301
2884
  /**
@@ -2328,7 +2911,7 @@ export class ControlDatabase {
2328
2911
  deleteStrand(
2329
2912
  strandId: string,
2330
2913
  ownerKey: string,
2331
- signMessage: (message: Uint8Array) => string
2914
+ signMessage: ControlSign
2332
2915
  ): Promise<boolean> {
2333
2916
  return this.lockedWithRetry(() => this.deleteStrandAndPartyKey(strandId, ownerKey, signMessage), {}, 'strand-delete');
2334
2917
  }
@@ -2348,7 +2931,7 @@ export class ControlDatabase {
2348
2931
  private async deleteStrandAndPartyKey(
2349
2932
  strandId: string,
2350
2933
  ownerKey: string,
2351
- signMessage: (message: Uint8Array) => string
2934
+ signMessage: ControlSign
2352
2935
  ): Promise<boolean> {
2353
2936
  this.ensureInitialized();
2354
2937
  // retry: false — these reads run inside the locked write body (see queryStampId's NOTE).
@@ -2359,10 +2942,10 @@ export class ControlDatabase {
2359
2942
  }
2360
2943
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
2361
2944
 
2362
- const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
2945
+ const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
2363
2946
  const partyKeyRemoval = partyKeyStamp === null
2364
2947
  ? null
2365
- : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
2948
+ : await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
2366
2949
 
2367
2950
  await this.inTransaction('delete Strand', async () => {
2368
2951
  await this.execGuardedRemoval(strandRemoval, ownerKey);
@@ -2400,26 +2983,37 @@ export class ControlDatabase {
2400
2983
  strandId: string,
2401
2984
  privateKey: string,
2402
2985
  ownerKey: string,
2403
- signMessage: (message: Uint8Array) => string
2986
+ signMessage: ControlSign
2404
2987
  ): Promise<void> {
2405
2988
  this.ensureInitialized();
2406
2989
  log('Inserting strand party key for strand: %s', strandId);
2990
+ const insert = await this.signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage);
2991
+ await this.execWrite(insert.sql, insert.params, 'strand-party-key-insert');
2992
+ log('Strand party key inserted for strand: %s', strandId);
2993
+ }
2407
2994
 
2408
- const peerId = this.config.libp2pNode.peerId.toString();
2409
- const stampId = generateStampId(peerId);
2995
+ /** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
2996
+ private async signStrandPartyKeyInsert(
2997
+ strandId: string,
2998
+ privateKey: string,
2999
+ ownerKey: string,
3000
+ signMessage: ControlSign
3001
+ ): Promise<SignedInsert> {
3002
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2410
3003
 
2411
3004
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
2412
3005
  // Id, PrivateKey, StampId.
2413
3006
  const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
2414
- const signature = signMessage(message);
2415
-
2416
- await this.execWrite(`
2417
- insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
2418
- with context OwnerKey = ?, Signature = ?
2419
- values (?, ?, ?)
2420
- `, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
3007
+ const signature = await signMessage(message);
2421
3008
 
2422
- log('Strand party key inserted for strand: %s', strandId);
3009
+ return {
3010
+ sql: `
3011
+ insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
3012
+ with context OwnerKey = ?, Signature = ?
3013
+ values (?, ?, ?)
3014
+ `,
3015
+ params: [ownerKey, signature, strandId, privateKey, stampId],
3016
+ };
2423
3017
  }
2424
3018
 
2425
3019
  /**
@@ -2437,7 +3031,7 @@ export class ControlDatabase {
2437
3031
  deleteStrandPartyKey(
2438
3032
  strandId: string,
2439
3033
  ownerKey: string,
2440
- signMessage: (message: Uint8Array) => string
3034
+ signMessage: ControlSign
2441
3035
  ): Promise<boolean> {
2442
3036
  return this.lockedWithRetry(() => this.deleteGuardedRow('StrandPartyKey', strandId, ownerKey, signMessage), {}, 'strand-party-key-delete');
2443
3037
  }
@@ -2457,7 +3051,7 @@ export class ControlDatabase {
2457
3051
  async insertJoinedStrand(
2458
3052
  row: Pick<StrandRow, 'Id' | 'Type' | 'MemberPrivateKey'>,
2459
3053
  ownerKey: string,
2460
- signMessage: (message: Uint8Array) => string
3054
+ signMessage: ControlSign
2461
3055
  ): Promise<void> {
2462
3056
  this.ensureInitialized();
2463
3057
  log('Inserting joined strand: %s (type: %s)', row.Id, row.Type);
@@ -2467,7 +3061,7 @@ export class ControlDatabase {
2467
3061
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
2468
3062
  // Id, Type, MemberPrivateKey ('' when null), StampId.
2469
3063
  const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
2470
- const signature = signMessage(message);
3064
+ const signature = await signMessage(message);
2471
3065
 
2472
3066
  await this.execWrite(`
2473
3067
  insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
@@ -2487,7 +3081,7 @@ export class ControlDatabase {
2487
3081
  deleteJoinedStrand(
2488
3082
  strandId: string,
2489
3083
  ownerKey: string,
2490
- signMessage: (message: Uint8Array) => string
3084
+ signMessage: ControlSign
2491
3085
  ): Promise<boolean> {
2492
3086
  return this.lockedWithRetry(() => this.deleteGuardedRow('JoinedStrand', strandId, ownerKey, signMessage), {}, 'joined-strand-delete');
2493
3087
  }
@@ -2502,10 +3096,10 @@ export class ControlDatabase {
2502
3096
  async insertJoinRequest(
2503
3097
  request: JoinRequestFields,
2504
3098
  ownerKey: string,
2505
- signMessage: (message: Uint8Array) => string
3099
+ signMessage: ControlSign
2506
3100
  ): Promise<PendingJoin> {
2507
3101
  this.ensureInitialized();
2508
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
3102
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2509
3103
  await this.execWrite(insert.sql, insert.params, 'join-request-insert');
2510
3104
  log('Join request inserted: %s', request.Id);
2511
3105
  return { ...written, outcome: null };
@@ -2528,10 +3122,10 @@ export class ControlDatabase {
2528
3122
  expected: PendingJoin,
2529
3123
  outcome: JoinOutcome,
2530
3124
  ownerKey: string,
2531
- signMessage: (message: Uint8Array) => string
3125
+ signMessage: ControlSign
2532
3126
  ): Promise<PendingJoin> {
2533
3127
  this.ensureInitialized();
2534
- const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
3128
+ const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2535
3129
  return this.lockedWithRetry(async () => {
2536
3130
  const live = await this.readPendingJoin(expected.Id, false);
2537
3131
  if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
@@ -2566,13 +3160,13 @@ export class ControlDatabase {
2566
3160
  expectedStampId: string,
2567
3161
  next: PendingJoinFields,
2568
3162
  ownerKey: string,
2569
- signMessage: (message: Uint8Array) => string
3163
+ signMessage: ControlSign
2570
3164
  ): Promise<PendingJoin> {
2571
3165
  this.ensureInitialized();
2572
3166
  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);
3167
+ const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
3168
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
3169
+ const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2576
3170
 
2577
3171
  return this.lockedWithRetry(async () => {
2578
3172
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
@@ -2603,7 +3197,7 @@ export class ControlDatabase {
2603
3197
  deletePendingJoin(
2604
3198
  id: string,
2605
3199
  ownerKey: string,
2606
- signMessage: (message: Uint8Array) => string
3200
+ signMessage: ControlSign
2607
3201
  ): Promise<boolean> {
2608
3202
  return this.lockedWithRetry(
2609
3203
  () => this.deleteGuardedRow('JoinRequest', id, ownerKey, signMessage, (stampId) => this.deleteJoinOutcomes(stampId)),
@@ -2614,13 +3208,13 @@ export class ControlDatabase {
2614
3208
  * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
2615
3209
  * verifies, returning the row as it will be written and its insert.
2616
3210
  */
2617
- private signJoinRequestInsert(
3211
+ private async signJoinRequestInsert(
2618
3212
  request: JoinRequestFields,
2619
3213
  ownerKey: string,
2620
- signMessage: (message: Uint8Array) => string
2621
- ): { written: JoinRequestRow; insert: SignedStatement } {
3214
+ signMessage: ControlSign
3215
+ ): Promise<{ written: JoinRequestRow; insert: SignedStatement }> {
2622
3216
  const written: JoinRequestRow = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
2623
- const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
3217
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2624
3218
  const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
2625
3219
  return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
2626
3220
  }
@@ -2660,7 +3254,7 @@ export class ControlDatabase {
2660
3254
  async insertValidationKey(
2661
3255
  key: string,
2662
3256
  ownerKey: string,
2663
- signMessage: (message: Uint8Array) => string
3257
+ signMessage: ControlSign
2664
3258
  ): Promise<void> {
2665
3259
  this.ensureInitialized();
2666
3260
  log('Inserting validation key: %s', key);
@@ -2670,7 +3264,7 @@ export class ControlDatabase {
2670
3264
 
2671
3265
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
2672
3266
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
2673
- const signature = signMessage(message);
3267
+ const signature = await signMessage(message);
2674
3268
 
2675
3269
  await this.execWrite(`
2676
3270
  insert into CadreControl.ValidationKey (Key, StampId)
@@ -2700,7 +3294,7 @@ export class ControlDatabase {
2700
3294
  deleteValidationKey(
2701
3295
  key: string,
2702
3296
  ownerKey: string,
2703
- signMessage: (message: Uint8Array) => string
3297
+ signMessage: ControlSign
2704
3298
  ): Promise<boolean> {
2705
3299
  return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage), {}, 'validation-key-delete');
2706
3300
  }
@@ -2752,13 +3346,13 @@ export class ControlDatabase {
2752
3346
  profile: NodeProfile | null;
2753
3347
  },
2754
3348
  ownerKey: string,
2755
- signMessage: (message: Uint8Array) => string
3349
+ signMessage: ControlSign
2756
3350
  ): Promise<boolean> {
2757
3351
  this.ensureInitialized();
2758
3352
  // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
2759
3353
  // row it vouches.
2760
3354
  const stampId = generateStampId(row.peerId);
2761
- const signature = signMessage(
3355
+ const signature = await signMessage(
2762
3356
  buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId])
2763
3357
  );
2764
3358
  return await this.mutateCadrePeer('peer-insert', async () => {
@@ -2783,32 +3377,44 @@ export class ControlDatabase {
2783
3377
  }
2784
3378
 
2785
3379
  /**
2786
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
2787
- * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
2788
- * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
2789
- * the row is re-emitted as a fresh, broadcasting transaction.
2790
- *
2791
- * This is the write-while-alone re-replication primitive: a membership row that committed
2792
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
2793
- * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
2794
- * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
2795
- * and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
2796
- * owner branch requires them kept: they are the machine's own claim) — so it is safe
2797
- * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
2798
- * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
2799
- * `UpdatedAt` without re-signing would invalidate its self-signature).
2800
- *
2801
- * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
2802
- * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
2803
- * membership snapshot cannot have changed.
2804
- *
2805
- * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
2806
- * that removes the row in between makes the `update` match nothing, and this still returns
2807
- * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
2808
- * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
2809
- * re-add in the same window fails loudly instead, since the signature binds the retired
2810
- * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
2811
- * into the locked body.
3380
+ * Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
3381
+ * (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
3382
+ * `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
3383
+ * {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
3384
+ * transaction, or stands on a different owner's voucher.
3385
+ *
3386
+ * Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
3387
+ * committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
3388
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
3389
+ * the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
3390
+ * stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
3391
+ * (the owner branch requires them kept: they are the machine's own claim) — so it is safe
3392
+ * over a row whose peer has not self-published (`Sig` null), and this method itself skips a
3393
+ * row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
3394
+ * bumping `UpdatedAt` without re-signing would invalidate its self-signature).
3395
+ *
3396
+ * The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
3397
+ * bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
3398
+ * only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
3399
+ * writes whether or not the machine has self-published (the owner branch has no monotonic
3400
+ * rule, so the column simply keeps its value).
3401
+ *
3402
+ * The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
3403
+ * row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
3404
+ * what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
3405
+ * the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
3406
+ * at the moment that publish does. A re-run after losing to it reads the self-signed row and
3407
+ * reports `'self-published'` instead of writing the owner branch over it — which would rewrite
3408
+ * `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
3409
+ * resolvers until the machine's next heartbeat. The decision has to be the body's for that;
3410
+ * a caller's pre-check outside the lock is only a fast path (see below).
3411
+ *
3412
+ * An absent row — none, or its stamp retired, or a stampless row no writer produces — is
3413
+ * `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
3414
+ * writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
3415
+ * ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
3416
+ * in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
3417
+ * which only re-reads the member set.
2812
3418
  *
2813
3419
  * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
2814
3420
  * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
@@ -2822,45 +3428,61 @@ export class ControlDatabase {
2822
3428
  *
2823
3429
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
2824
3430
  * 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.
3431
+ * reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
3432
+ * re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
3433
+ * rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
3434
+ * key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
3435
+ * flips the voucher silently: readers that anchor the original owner but not this one would
3436
+ * drop a legitimate member. It must re-vouch deliberately (or preserve the existing
3437
+ * VouchOwner/VouchSig) rather than inherit this rebinding.
2832
3438
  *
2833
3439
  * @param peerId - the membership row to re-touch.
2834
- * @param updatedAt - the strictly-increasing freshness stamp to write.
3440
+ * @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
2835
3441
  * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
2836
- * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
2837
- * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
3442
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
3443
+ * called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
3444
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}).
2838
3445
  */
2839
3446
  async reauthorizeCadrePeer(
2840
3447
  peerId: string,
2841
- updatedAt: number,
3448
+ mode: CadrePeerRetouchMode,
2842
3449
  ownerKey: string,
2843
- signMessage: (message: Uint8Array) => string
2844
- ): Promise<boolean> {
3450
+ signMessage: ControlSign
3451
+ ): Promise<CadrePeerRetouchOutcome> {
2845
3452
  this.ensureInitialized();
2846
- const stampId = await this.queryStampId('CadrePeer', peerId);
2847
- if (stampId === null) {
2848
- log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
2849
- return false;
3453
+ const outside = retouchTarget(await this.findCadrePeerRow(peerId, true), mode);
3454
+ if (typeof outside === 'string') {
3455
+ log('reauthorizeCadrePeer(%s): %s for %s (nothing to write)', mode, outside, peerId);
3456
+ return outside;
2850
3457
  }
2851
- const signature = signMessage(
2852
- buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId])
2853
- );
2854
- await this.mutateCadrePeer('peer-reauthorize', async () => {
3458
+ return await this.mutateCadrePeer('peer-reauthorize', async () => {
3459
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
3460
+ const target = retouchTarget(await this.findCadrePeerRow(peerId, false), mode);
3461
+ if (typeof target === 'string') {
3462
+ log('reauthorizeCadrePeer(%s): %s for %s since the pre-check (nothing to write)', mode, target, peerId);
3463
+ return target;
3464
+ }
3465
+ const signature = await signMessage(
3466
+ buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, target.stampId])
3467
+ );
2855
3468
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2856
- await this.db!.exec(`
2857
- update CadreControl.CadrePeer
2858
- with context OwnerKey = ?, Signature = ?
2859
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2860
- where PeerId = ?
2861
- `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
2862
- });
2863
- return true;
3469
+ if (mode === 'rebroadcast') {
3470
+ await this.db!.exec(`
3471
+ update CadreControl.CadrePeer
3472
+ with context OwnerKey = ?, Signature = ?
3473
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
3474
+ where PeerId = ?
3475
+ `, [ownerKey, signature, Math.max(Date.now(), target.updatedAt + 1), ownerKey, signature, peerId]);
3476
+ } else {
3477
+ await this.db!.exec(`
3478
+ update CadreControl.CadrePeer
3479
+ with context OwnerKey = ?, Signature = ?
3480
+ set VouchOwner = ?, VouchSig = ?, VouchUsage = null
3481
+ where PeerId = ?
3482
+ `, [ownerKey, signature, ownerKey, signature, peerId]);
3483
+ }
3484
+ return 'retouched';
3485
+ }, LOST_UPDATE_RETRY_POLICY);
2864
3486
  }
2865
3487
 
2866
3488
  /**
@@ -2882,14 +3504,20 @@ export class ControlDatabase {
2882
3504
  *
2883
3505
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
2884
3506
  * when a row was actually removed.
3507
+ *
3508
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
3509
+ * at the moment of the removal (its first publish lands about a second after it starts), and
3510
+ * the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
3511
+ * and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
3512
+ * binds, so the re-run reads the published row and removes it.
2885
3513
  */
2886
3514
  deleteCadrePeer(
2887
3515
  peerId: string,
2888
3516
  ownerKey: string,
2889
- signMessage: (message: Uint8Array) => string
3517
+ signMessage: ControlSign
2890
3518
  ): Promise<boolean> {
2891
3519
  return this.mutateCadrePeer('peer-remove', () =>
2892
- this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
3520
+ this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage), LOST_UPDATE_RETRY_POLICY);
2893
3521
  }
2894
3522
 
2895
3523
  /**
@@ -2908,7 +3536,7 @@ export class ControlDatabase {
2908
3536
  deleteDeviceToken(
2909
3537
  peerId: string,
2910
3538
  ownerKey: string,
2911
- signMessage: (message: Uint8Array) => string
3539
+ signMessage: ControlSign
2912
3540
  ): Promise<boolean> {
2913
3541
  return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage), {}, 'device-token-delete');
2914
3542
  }
@@ -2944,7 +3572,7 @@ export class ControlDatabase {
2944
3572
  table: RemovableTable,
2945
3573
  keyValue: string,
2946
3574
  ownerKey: string,
2947
- signMessage: (message: Uint8Array) => string,
3575
+ signMessage: ControlSign,
2948
3576
  alongside?: (stampId: string) => Promise<void>
2949
3577
  ): Promise<boolean> {
2950
3578
  this.ensureInitialized();
@@ -2955,7 +3583,7 @@ export class ControlDatabase {
2955
3583
  return false;
2956
3584
  }
2957
3585
 
2958
- const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
3586
+ const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2959
3587
  await this.inTransaction(`delete ${table}`, async () => {
2960
3588
  await alongside?.(stampId);
2961
3589
  await this.execGuardedRemoval(removal, ownerKey);
@@ -2991,10 +3619,9 @@ export class ControlDatabase {
2991
3619
 
2992
3620
  /**
2993
3621
  * 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
2996
- * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2997
- * lock and, where one is needed, the transaction.
3622
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
3623
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
3624
+ * supplies the lock and, where one is needed, the transaction.
2998
3625
  *
2999
3626
  * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
3000
3627
  * than leaning on the column default — the seat-at-zero rule is load-bearing for
@@ -3003,11 +3630,7 @@ export class ControlDatabase {
3003
3630
  * `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
3004
3631
  * equal), so a node that receives the row by replication can tell which owner filed it.
3005
3632
  */
3006
- private async execTombstone(
3007
- ref: RevokedRowRef | typeof REVOCATION_LEDGER_MARKER,
3008
- revocationSignature: string,
3009
- ownerKey: string
3010
- ): Promise<void> {
3633
+ private async execTombstone(ref: RevokedRowRef, revocationSignature: string, ownerKey: string): Promise<void> {
3011
3634
  await this.db!.exec(`
3012
3635
  insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
3013
3636
  with context OwnerKey = ?, Signature = ?
@@ -3053,9 +3676,10 @@ export class ControlDatabase {
3053
3676
  * statement, and without the clause the reap would delete the owner's brand-new row.
3054
3677
  * With it the statement matches nothing and the reap is a silent no-op (the schema
3055
3678
  * branch would also refuse that delete, but as a constraint error thrown into a
3056
- * background sweep — matched-nothing is the better failure mode). Like
3057
- * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
3058
- * harmless — nothing was deleted, and the next sweep re-reads.
3679
+ * background sweep — matched-nothing is the better failure mode). A race lost AFTER the
3680
+ * guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
3681
+ * its lock and reports what it found); harmless — nothing was deleted, and the next sweep
3682
+ * re-reads.
3059
3683
  *
3060
3684
  * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
3061
3685
  * committed tombstone that authorizes the reap, so a reap files no second tombstone.
@@ -3082,7 +3706,7 @@ export class ControlDatabase {
3082
3706
  * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
3083
3707
  *
3084
3708
  * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
3085
- * reconcile pass runs while the node has at least one control connection.
3709
+ * reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
3086
3710
  */
3087
3711
  async reapRevokedRow(table: ReapableTable, rowKey: string, stampId: string): Promise<boolean> {
3088
3712
  this.ensureInitialized();
@@ -3139,8 +3763,8 @@ export class ControlDatabase {
3139
3763
  * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
3140
3764
  * that converged on a removal while holding the removed row keeps that row as inert
3141
3765
  * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
3142
- * control-cohort reconcile pass is that something, gated on the node having at least one
3143
- * control connection (a reap is a write, and a write committed alone is local-only).
3766
+ * control-cohort reconcile pass is that something, gated on a sibling being reachable
3767
+ * (a reap is a write, and a write committed alone is local-only).
3144
3768
  *
3145
3769
  * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
3146
3770
  * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
@@ -3173,12 +3797,11 @@ export class ControlDatabase {
3173
3797
  *
3174
3798
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
3175
3799
  * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
3176
- * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
3177
- * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
3178
- * only while `Revocation` has never been written; the ledger marker
3179
- * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
3800
+ * the common case (a party that has never revoked anyone) a single scan per pass, served
3801
+ * from the `Revocation` membership view
3802
+ * ({@link readMembershipRows}) unless the table changed since the last read.
3180
3803
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
3181
- * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
3804
+ * lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
3182
3805
  * party; if the table ever grows, persist a node-local high-water mark of what has
3183
3806
  * already been reaped instead of re-scanning everything (the same bound the sweep in
3184
3807
  * `CadreNode.drainPendingRevocations` wants).
@@ -3243,25 +3866,28 @@ export class ControlDatabase {
3243
3866
  *
3244
3867
  * Returns how many UPDATE statements ran (`rows.length` on success).
3245
3868
  */
3246
- reissueRevocations(
3869
+ async reissueRevocations(
3247
3870
  rows: readonly RevocationRow[],
3248
3871
  reissuedAt: number,
3249
3872
  ownerKey: string,
3250
- signMessage: (message: Uint8Array) => string
3873
+ signMessage: ControlSign
3251
3874
  ): Promise<number> {
3252
3875
  this.ensureInitialized();
3253
3876
  // Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
3254
3877
  // exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
3255
3878
  // rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
3256
3879
  // 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 () => {
3880
+ const signed: Array<{ row: RevocationRow; signature: string }> = [];
3881
+ for (const row of rows) {
3882
+ signed.push({
3883
+ row,
3884
+ signature: await signMessage(buildAuthorizationMessage(
3885
+ 'CadreControl.Revocation', 'reissue', this.config.partyId,
3886
+ [row.tableName, row.rowKey, row.stampId, String(reissuedAt)]
3887
+ )),
3888
+ });
3889
+ }
3890
+ return await this.lockedWithRetry(async () => {
3265
3891
  // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
3266
3892
  // re-executes the whole body) starts from zero instead of double-counting.
3267
3893
  let executed = 0;
@@ -3293,78 +3919,6 @@ export class ControlDatabase {
3293
3919
  }, {}, 'revocation-reissue');
3294
3920
  }
3295
3921
 
3296
- /**
3297
- * Owner-signed filing of the singleton `Revocation` ledger marker
3298
- * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
3299
- *
3300
- * Why it exists: while `Revocation` has never been written, this node does not hold its
3301
- * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
3302
- * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
3303
- * reads this table. Once any row exists the block is held and re-checked at most once per
3304
- * read-repair window, like every other populated table. The schema's table comment says
3305
- * why the row can never read as a retirement.
3306
- *
3307
- * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
3308
- * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
3309
- * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
3310
- * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
3311
- * full composite primary key, which is served as a point lookup that can miss an existing
3312
- * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
3313
- *
3314
- * Two owners filing at once: the loser either sees the marker in its guard or is refused
3315
- * on the primary key, and both answer `'already-open'`.
3316
- *
3317
- * The caller decides WHEN. A marker committed while the node is alone is local-only and
3318
- * can fork the collection, so {@link CadreNode} files it only while connected; this
3319
- * method does not look at connectivity.
3320
- *
3321
- * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
3322
- * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
3323
- * private key, returning a base64url signature.
3324
- * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
3325
- * already there.
3326
- */
3327
- async openRevocationLedger(
3328
- ownerKey: string,
3329
- signMessage: (message: Uint8Array) => string
3330
- ): Promise<RevocationLedgerOpenResult> {
3331
- this.ensureInitialized();
3332
- const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
3333
- try {
3334
- return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
3335
- if (await this.revocationLedgerFiled()) {
3336
- return 'already-open';
3337
- }
3338
- // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
3339
- await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
3340
- log('Revocation ledger marker filed');
3341
- return 'opened';
3342
- }, {}, 'revocation-ledger-open');
3343
- } catch (error) {
3344
- if (!isRevocationLedgerConflict(error)) {
3345
- throw error;
3346
- }
3347
- log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
3348
- return 'already-open';
3349
- }
3350
- }
3351
-
3352
- /**
3353
- * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
3354
- * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
3355
- * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
3356
- */
3357
- private async revocationLedgerFiled(): Promise<boolean> {
3358
- const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
3359
- const rows = await this.readRows(
3360
- 'select StampId from CadreControl.Revocation where TableName = ?',
3361
- [tableName],
3362
- 'revocation-ledger',
3363
- false
3364
- );
3365
- return rows.some(row => row.StampId === stampId);
3366
- }
3367
-
3368
3922
  /**
3369
3923
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
3370
3924
  *
@@ -3411,11 +3965,28 @@ export class ControlDatabase {
3411
3965
  * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
3412
3966
  * second call replaces the first rather than fanning out, so a stale node can
3413
3967
  * never keep receiving notifications for a database it no longer owns.
3968
+ *
3969
+ * Only this database's own writes notify here, awaited inside the write so the gate is
3970
+ * current before the write returns. A membership change replicated in from another
3971
+ * machine reaches the gate through the subscription `CadreNode.start` takes on the
3972
+ * membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
3973
+ * lands in storage; one storage never hears of waits for the reconcile pass's backstop.
3414
3974
  */
3415
3975
  setMembershipChangeListener(listener: MembershipChangeListener | null): void {
3416
3976
  this.membershipListener = listener;
3417
3977
  }
3418
3978
 
3979
+ /**
3980
+ * Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
3981
+ * made here and changes replicated into this node's storage alike, on the single-flight
3982
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
3983
+ * still live before it drains the write queue.
3984
+ */
3985
+ watchTables(tables: readonly string[], onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
3986
+ this.ensureInitialized();
3987
+ return this.tableWatches.watch(this.db!, tables, onChange, options);
3988
+ }
3989
+
3419
3990
  /**
3420
3991
  * Wire (or clear, with null) the single listener notified after every committed
3421
3992
  * guarded-table delete. Same ownership contract as
@@ -3447,8 +4018,9 @@ export class ControlDatabase {
3447
4018
  * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
3448
4019
  * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
3449
4020
  * 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
4021
+ * {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
4022
+ * seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
4023
+ * makes the party-membership snapshot refresh automatic
3452
4024
  * rather than a caller obligation. The name predates the `OwnerKey` writers. A
3453
4025
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
3454
4026
  * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
@@ -3475,8 +4047,11 @@ export class ControlDatabase {
3475
4047
  * exhaustion.
3476
4048
  *
3477
4049
  * @param reason - Label for the log line only (e.g. `'peer-insert'`).
4050
+ * @param policy - The retry policy, default unless the body has earned a wider one: the three
4051
+ * `CadrePeer` writers whose bodies re-read the row and decide inside pass
4052
+ * `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
3478
4053
  */
3479
- async mutateCadrePeer<T>(reason: string, body: () => Promise<T>): Promise<T> {
4054
+ async mutateCadrePeer<T>(reason: string, body: () => Promise<T>, policy: ControlWriteRetryOptions = {}): Promise<T> {
3480
4055
  this.ensureInitialized();
3481
4056
  return this.lockedWithRetry(async () => {
3482
4057
  this.assertCommitBoundary(reason, 'on entry to');
@@ -3484,7 +4059,7 @@ export class ControlDatabase {
3484
4059
  this.assertCommitBoundary(reason, 'on return from');
3485
4060
  await this.notifyMembershipChanged(reason);
3486
4061
  return result;
3487
- }, {}, reason);
4062
+ }, policy, reason);
3488
4063
  }
3489
4064
 
3490
4065
  /**
@@ -3499,8 +4074,8 @@ export class ControlDatabase {
3499
4074
  * class: every public write method of this class runs its statement(s) under it, and
3500
4075
  * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
3501
4076
  * `Database` directly, so it is covered too. The race is
3502
- * real, not theoretical: the background self-record publish on a control connection
3503
- * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
4077
+ * real, not theoretical: the background self-record publish when a sibling becomes
4078
+ * reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
3504
4079
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
3505
4080
  * assert.
3506
4081
  *
@@ -3524,6 +4099,11 @@ export class ControlDatabase {
3524
4099
  * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
3525
4100
  * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
3526
4101
  * attempt: a retry re-presents the exact signed message the first attempt presented.
4102
+ * A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
4103
+ * its re-run is a re-decision over a row another writer changed, not a re-presentation of
4104
+ * the same write, so its read of the row, its decision and its signature all run in the
4105
+ * body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
4106
+ * under {@link deleteCadrePeer}, {@link removeMachine}).
3527
4107
  */
3528
4108
  async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
3529
4109
  // Chain behind the current tail regardless of how it settled, then park a
@@ -3561,13 +4141,18 @@ export class ControlDatabase {
3561
4141
  * backoff never stalls the other local writers queued behind it. Safe because every
3562
4142
  * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
3563
4143
  *
3564
- * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
4144
+ * Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
3565
4145
  * `initialized` is set — so it reaches this method directly rather than through
3566
4146
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
3567
4147
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
3568
- * rather than carving out a lockless path. It is also the ONLY caller that passes a
3569
- * `policy` — everything else takes the default, whose classifier and attempt count are
3570
- * unchanged by that call site existing.
4148
+ * rather than carving out a lockless path.
4149
+ *
4150
+ * Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
4151
+ * call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
4152
+ * row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
4153
+ * {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
4154
+ * policy parameter). Everything else takes the default, whose classifier and attempt count
4155
+ * are unchanged by those call sites existing.
3571
4156
  *
3572
4157
  * `label` names the operation in this loop's debug lines and nothing else
3573
4158
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
@@ -3707,12 +4292,27 @@ export class ControlDatabase {
3707
4292
  token: string,
3708
4293
  sAppId: string,
3709
4294
  ownerKey: string,
3710
- signMessage: (message: Uint8Array) => string,
4295
+ signMessage: ControlSign,
3711
4296
  options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
3712
4297
  ): Promise<void> {
3713
4298
  this.ensureInitialized();
3714
4299
  log('Inserting formation invite: %s', token);
4300
+ const insert = await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options);
4301
+ await this.execWrite(insert.sql, insert.params, 'formation-invite-insert');
4302
+ log('Formation invite inserted: %s', token);
4303
+ }
3715
4304
 
4305
+ /**
4306
+ * The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
4307
+ * {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
4308
+ */
4309
+ private async signFormationInviteInsert(
4310
+ token: string,
4311
+ sAppId: string,
4312
+ ownerKey: string,
4313
+ signMessage: ControlSign,
4314
+ options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string }
4315
+ ): Promise<SignedInsert> {
3716
4316
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
3717
4317
 
3718
4318
  // Build each bound field exactly as the deferred CHECK sees it post-coercion:
@@ -3734,26 +4334,27 @@ export class ControlDatabase {
3734
4334
  const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
3735
4335
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
3736
4336
  ]);
3737
- const signature = signMessage(message);
4337
+ const signature = await signMessage(message);
3738
4338
 
3739
4339
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
3740
4340
  // signed source-of-truth and the stored value are produced once. StampId is a real,
3741
4341
  // unique column (single-use anti-replay), no longer a context value.
3742
- await this.execWrite(`
3743
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3744
- with context OwnerKey = ?, Signature = ?
3745
- values (?, ?, ?, ?, ?, ?, ?)
3746
- `, [
3747
- ownerKey, signature,
3748
- token, sAppId,
3749
- expiresAtCanonical,
3750
- options.totalUses ?? null,
3751
- options.validationUrl ?? null,
3752
- options.strandId ?? null,
3753
- stampId,
3754
- ], 'formation-invite-insert');
3755
-
3756
- log('Formation invite inserted: %s', token);
4342
+ return {
4343
+ sql: `
4344
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
4345
+ with context OwnerKey = ?, Signature = ?
4346
+ values (?, ?, ?, ?, ?, ?, ?)
4347
+ `,
4348
+ params: [
4349
+ ownerKey, signature,
4350
+ token, sAppId,
4351
+ expiresAtCanonical,
4352
+ options.totalUses ?? null,
4353
+ options.validationUrl ?? null,
4354
+ options.strandId ?? null,
4355
+ stampId,
4356
+ ],
4357
+ };
3757
4358
  }
3758
4359
 
3759
4360
  /**
@@ -3842,13 +4443,13 @@ export class ControlDatabase {
3842
4443
  await this.inTransaction('redemption', async () => {
3843
4444
  // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
3844
4445
  // still carrying a fresh unique StampId for the anti-replay column.
3845
- // Hard-coded open + keyless + explicit null FounderOwnerKey: the
4446
+ // Hard-coded open + keyless + explicit null founder columns: the
3846
4447
  // consent branch admits no other shape (no signature means no
3847
- // trustworthy founder machine to record — see the schema's NOTE).
4448
+ // trustworthy founder machine to record — see the schema's comment).
3848
4449
  await this.db!.exec(`
3849
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
4450
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
3850
4451
  with context OwnerKey = null, Signature = null
3851
- values (?, 'o', null, ?, null)
4452
+ values (?, 'o', null, ?, null, null)
3852
4453
  `, [strandId, strandStampId]);
3853
4454
 
3854
4455
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
@@ -3876,14 +4477,13 @@ export class ControlDatabase {
3876
4477
  * Use {@link redeemInvitation} instead when the strand must be created by
3877
4478
  * consent atomically with the usage.
3878
4479
  *
3879
- * The strand's live `StampId` is read first and written onto the usage row:
4480
+ * The strand's live `StampId` is written onto the usage row:
3880
4481
  * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
3881
4482
  * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
3882
- * record authorizes exactly the strand ROW it was recorded against. A missing strand
3883
- * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
3884
- * ordinary "host strand has not converged yet" case is already reported as `missing`
3885
- * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
3886
- * point is a genuine race and deserves a named error, not a silent rollback.
4483
+ * record authorizes exactly the strand ROW it was recorded against. A caller that already
4484
+ * read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
4485
+ * missing strand THROWS {@link MissingHostStrandError} rather than being left to the
4486
+ * deferred `StrandExists` CHECK.
3887
4487
  *
3888
4488
  * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
3889
4489
  * path — the one production actually races on (a bound invite published by `cadre-web` /
@@ -3918,6 +4518,13 @@ export class ControlDatabase {
3918
4518
  * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
3919
4519
  */
3920
4520
  totalUses?: number | null;
4521
+ /**
4522
+ * The host strand's `StampId`, when the caller already read the row
4523
+ * ({@link queryStrandWithStamp}); `undefined` reads it here. A stamp is fixed for the life
4524
+ * of its row, so one read earlier is the stamp `StrandExists` compares, unless the row was
4525
+ * removed (and re-seated) in between, which that CHECK refuses at commit.
4526
+ */
4527
+ strandStampId?: string;
3921
4528
  }): Promise<FormationUsageResult> {
3922
4529
  this.ensureInitialized();
3923
4530
  const {
@@ -3928,7 +4535,7 @@ export class ControlDatabase {
3928
4535
 
3929
4536
  // The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
3930
4537
  // consent record authorizes.
3931
- const strandStampId = await this.queryStrandStampId(strandId);
4538
+ const strandStampId = params.strandStampId ?? await this.queryStrandStampId(strandId);
3932
4539
  if (strandStampId === null) {
3933
4540
  throw new MissingHostStrandError(strandId, token);
3934
4541
  }
@@ -4072,7 +4679,7 @@ export class ControlDatabase {
4072
4679
  * permissive PRE-check that runs ahead of it, and a transiently short read costs each of
4073
4680
  * them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
4074
4681
  * {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
4075
- * generic refusal; `ControlFormationUsageRecorder.isTokenUsed` reports not-used and lets
4682
+ * generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
4076
4683
  * the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
4077
4684
  * holds the stranger-admission door open slightly longer.
4078
4685
  *
@@ -4106,12 +4713,12 @@ export class ControlDatabase {
4106
4713
  /**
4107
4714
  * Is any `FormationInvite` row still redeemable — unexpired AND with usage
4108
4715
  * below its `TotalUses`? A null `ExpiresAt` never expires and a null
4109
- * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
4110
- * per-token semantics (`isTokenValid` / `isTokenUsed`).
4716
+ * `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
4717
+ * (`formation-token.ts`).
4111
4718
  *
4112
4719
  * Answers the control-network connection gate's coarse "does this node expect
4113
4720
  * a stranger?" question, which has no token to ask about. The expiry
4114
- * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
4721
+ * comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
4115
4722
  * invite the formation handler would reject can never hold the gate open.
4116
4723
  *
4117
4724
  * The scan is deliberately not pushed into SQL: nothing else here compares a
@@ -4180,84 +4787,121 @@ export class ControlDatabase {
4180
4787
  * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
4181
4788
  * same shape).
4182
4789
  *
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
4790
  * @returns the row as stored, `expiresAt` in its canonical form
4189
4791
  */
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> {
4792
+ async insertCadreInvite(invite: CadreInviteFields, ownerKey: string, signMessage: ControlSign): Promise<CadreInviteRow> {
4793
+ const row = await this.signCadreInvite(invite, ownerKey, signMessage);
4794
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
4795
+ log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
4796
+ return row;
4797
+ }
4798
+
4799
+ /**
4800
+ * Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
4801
+ * {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
4802
+ * invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
4803
+ * issuer would refuse the local insert and the row is seated at a member from the bundle
4804
+ * instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
4805
+ * store, so the row reads the same wherever it is seated.
4806
+ */
4807
+ async signCadreInvite(invite: CadreInviteFields, ownerKey: string, signMessage: ControlSign): Promise<CadreInviteRow> {
4201
4808
  this.ensureInitialized();
4202
4809
  const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
4203
4810
  const signed: CadreInviteSignedFields = {
4204
4811
  key,
4205
4812
  peerId: invite.peerId ?? null,
4206
- grantsOwner: invite.grantsOwner,
4207
4813
  expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db!, invite.expiresAtMs),
4208
4814
  totalUses: invite.totalUses ?? null,
4209
4815
  stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
4210
4816
  };
4211
- const row: CadreInviteRow = {
4817
+ return {
4212
4818
  ...signed,
4213
4819
  issuerKey: ownerKey,
4214
- issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
4820
+ issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
4215
4821
  };
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
4822
  }
4220
4823
 
4221
4824
  /**
4222
4825
  * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
4223
4826
  * 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.
4827
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
4828
+ * and so is one whose insert the cohort refuses because a member there holds it.
4225
4829
  *
4226
4830
  * @returns `true` when this call inserted the row, `false` when it was already present.
4227
4831
  * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
4228
4832
  * 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.
4833
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
4834
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4230
4835
  */
4231
4836
  async seatCadreInvite(row: CadreInviteRow): Promise<boolean> {
4837
+ return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row),
4838
+ cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
4839
+ }
4840
+
4841
+ /**
4842
+ * Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
4843
+ * {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
4844
+ * row's own stored issuer pair.
4845
+ *
4846
+ * @returns `true` when this call inserted the row, `false` when it was already present —
4847
+ * here, or at the cohort member that refused the insert for holding it
4848
+ * ({@link adoptCohortHeldRow}).
4849
+ * @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
4850
+ * {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
4851
+ * read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4852
+ */
4853
+ private async seatIssuedRow(table: IssuedTable, key: string, exec: () => Promise<void>, refuse: (cause: unknown) => Error): Promise<boolean> {
4232
4854
  this.ensureInitialized();
4233
4855
  try {
4234
4856
  return await this.lockedWithRetry(async () => {
4235
4857
  // 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);
4858
+ if (await this.queryStampId(table, key, false) !== null) {
4859
+ log('%s %s already seated; seat skipped', table, key);
4238
4860
  return false;
4239
4861
  }
4240
- await this.execCadreInviteInsert(row);
4241
- log('Cadre invitation seated from its bundle: %s', row.key);
4862
+ await exec();
4863
+ log('%s seated from its bundle: %s', table, key);
4242
4864
  return true;
4243
- }, {}, 'cadre-invite-seat');
4865
+ }, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
4244
4866
  } catch (error) {
4245
- if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
4246
- throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
4867
+ if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
4868
+ throw refuse(error);
4869
+ }
4870
+ if (isIssuedRowKeyConflict(error, table)) {
4871
+ return await this.adoptCohortHeldRow(table, key, error);
4247
4872
  }
4248
4873
  throw error;
4249
4874
  }
4250
4875
  }
4251
4876
 
4877
+ /**
4878
+ * The seat's insert was refused because a cohort member already holds a row under `key`, which
4879
+ * this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
4880
+ * and every column is under the issuer's signature, so the held row is this one: it counts as
4881
+ * seated once a refreshing read (unlocked, so retried as any read) finds it here.
4882
+ *
4883
+ * @throws {IssuedRowNotHereYetError} when the read still does not find it.
4884
+ */
4885
+ private async adoptCohortHeldRow(table: IssuedTable, key: string, conflict: unknown): Promise<false> {
4886
+ // NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
4887
+ // which does not refresh from the cohort, so it can miss the row and refuse a retryable
4888
+ // `conflict`; if that refusal shows up in practice, wait for the running body before reading.
4889
+ if (await this.queryStampId(table, key) === null) {
4890
+ throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
4891
+ }
4892
+ log('%s %s is held by the cohort; seat skipped', table, key);
4893
+ return false;
4894
+ }
4895
+
4252
4896
  /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
4253
4897
  private async execCadreInviteInsert(row: CadreInviteRow): Promise<void> {
4254
4898
  await this.db!.exec(`
4255
- insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4899
+ insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4256
4900
  with context OwnerKey = ?, Signature = ?
4257
- values (?, ?, ?, ?, ?, ?, ?, ?)
4901
+ values (?, ?, ?, ?, ?, ?, ?)
4258
4902
  `, [
4259
4903
  row.issuerKey, row.issuerSig,
4260
- row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
4904
+ row.key, row.peerId, row.expiresAt, row.totalUses,
4261
4905
  row.issuerKey, row.issuerSig, row.stampId,
4262
4906
  ]);
4263
4907
  }
@@ -4267,8 +4911,10 @@ export class ControlDatabase {
4267
4911
  * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
4268
4912
  * question, and `CadreInviteUsage.Authorized`'s at redemption.
4269
4913
  *
4270
- * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
4271
- * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4914
+ * A redemption reads the row once, before its write, and carries it into
4915
+ * {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
4916
+ * {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
4917
+ * the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4272
4918
  */
4273
4919
  async queryCadreInvite(key: string, retry = true): Promise<CadreInviteRow | null> {
4274
4920
  this.ensureInitialized();
@@ -4286,23 +4932,16 @@ export class ControlDatabase {
4286
4932
  * {@link queryCadrePeers}' reason.
4287
4933
  */
4288
4934
  async queryCadreInvites(retry = true): Promise<CadreInviteRow[]> {
4289
- this.ensureInitialized();
4290
- const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
4291
- return rows.map(cadreInviteRowOf);
4935
+ return (await this.readMembershipRows(this.cadreInviteView, retry)).map(cadreInviteRowOf);
4292
4936
  }
4293
4937
 
4294
4938
  /**
4295
4939
  * Every `CadreInviteUsage` row this node holds — the redemption records the membership
4296
4940
  * predicate verifies invitation-admitted rows against. Same `retry` contract as
4297
- * {@link queryCadreInvites}.
4941
+ * {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
4298
4942
  */
4299
4943
  async queryCadreInviteUsages(retry = true): Promise<CadreInviteUsageRow[]> {
4300
- this.ensureInitialized();
4301
- const rows = await this.readRows(
4302
- 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
4303
- undefined, 'cadre-invite-usages', retry
4304
- );
4305
- return rows.map(cadreInviteUsageRowOf);
4944
+ return (await this.readMembershipRows(this.cadreInviteUsageView, retry)).map(cadreInviteUsageRowOf);
4306
4945
  }
4307
4946
 
4308
4947
  /**
@@ -4313,9 +4952,13 @@ export class ControlDatabase {
4313
4952
  * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
4314
4953
  */
4315
4954
  async countCadreInviteUsage(key: string, retry = true): Promise<number> {
4955
+ return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
4956
+ }
4957
+
4958
+ /** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
4959
+ private async countUsageRows(sql: string, key: string, label: string, retry: boolean): Promise<number> {
4316
4960
  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)) {
4961
+ for (const row of await this.readRows(sql, [key], label, retry)) {
4319
4962
  return (row.Count as number) ?? 0;
4320
4963
  }
4321
4964
  return 0;
@@ -4344,6 +4987,9 @@ export class ControlDatabase {
4344
4987
  // counts make that free today; if a long-lived cadre accumulates thousands and inbound
4345
4988
  // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
4346
4989
  // first needs every device they admitted re-vouched, so the chain no longer needs them).
4990
+ // NOTE: this scan reaches storage while queryCadreInvites serves the same rows from the
4991
+ // membership view; if the stranger path's reads ever near the gate's 2 s deadline, walk
4992
+ // queryCadreInvites() here instead.
4347
4993
  const metered: Array<{ key: string; totalUses: number }> = [];
4348
4994
  let unlimitedLive = false;
4349
4995
  for (const row of await this.readRows(
@@ -4351,7 +4997,7 @@ export class ControlDatabase {
4351
4997
  undefined,
4352
4998
  'live-cadre-invites'
4353
4999
  )) {
4354
- const open = cadreInviteStillOpen(
5000
+ const open = issuedRowStillOpen(
4355
5001
  { stampId: row.StampId as string, issuerKey: row.IssuerKey as string, expiresAt: (row.ExpiresAt as string | null) ?? null },
4356
5002
  withdrawn, owners, nowMs
4357
5003
  );
@@ -4381,24 +5027,42 @@ export class ControlDatabase {
4381
5027
 
4382
5028
  /**
4383
5029
  * Is this one cadre invitation still redeemable here — the conditions
4384
- * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
4385
- * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
4386
- * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
4387
- * judged by whichever owner it names as issuer. A permissive pre-check like
4388
- * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
4389
- * what enforce every condition.
4390
- */
4391
- async isCadreInviteLive(invite: CadreInviteRow, nowMs: number = Date.now()): Promise<boolean> {
5030
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
5031
+ * node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
5032
+ * none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
5033
+ * verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
5034
+ * issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
5035
+ * and the redemption's own write are what enforce every condition.
5036
+ *
5037
+ * The use count read here is read again inside the redemption's write lock
5038
+ * ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
5039
+ * pushed the control store before the write, so the holder of a spent invitation is refused
5040
+ * without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
5041
+ */
5042
+ async isCadreInviteLive(invite: CadreInviteRow, held: CadreInviteRow | null, nowMs: number = Date.now()): Promise<boolean> {
4392
5043
  this.ensureInitialized();
4393
- const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
5044
+ const row = held ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
5045
+ return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
5046
+ }
5047
+
5048
+ /**
5049
+ * The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
5050
+ * or a verified bundle copy; null for neither) still open and under its use cap?
5051
+ */
5052
+ private async issuedRowLive(
5053
+ table: IssuedTable,
5054
+ row: { key: string; stampId: string; issuerKey: string; expiresAt: string | null; totalUses: number | null } | null,
5055
+ nowMs: number,
5056
+ countUsage: (key: string) => Promise<number>
5057
+ ): Promise<boolean> {
4394
5058
  if (row === null) {
4395
5059
  return false;
4396
5060
  }
4397
- const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
4398
- if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
5061
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
5062
+ if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
4399
5063
  return false;
4400
5064
  }
4401
- return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
5065
+ return row.totalUses === null || await countUsage(row.key) < row.totalUses;
4402
5066
  }
4403
5067
 
4404
5068
  /**
@@ -4409,22 +5073,34 @@ export class ControlDatabase {
4409
5073
  * occasional read, not a gate's.
4410
5074
  */
4411
5075
  async listCadreInviteStatuses(nowMs: number = Date.now()): Promise<CadreInviteStatus[]> {
5076
+ const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
5077
+ return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
5078
+ }
5079
+
5080
+ /**
5081
+ * The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
5082
+ * `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
5083
+ */
5084
+ private async issuedRowStandings<Row extends { key: string; stampId: string; issuerKey: string; expiresAt: string | null; totalUses: number | null }>(
5085
+ table: IssuedTable,
5086
+ rows: readonly Row[],
5087
+ nowMs: number,
5088
+ countUsage: (key: string) => Promise<number>
5089
+ ): Promise<Array<IssuedRowStanding<Row>>> {
4412
5090
  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 });
5091
+ const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
5092
+ const standings: Array<IssuedRowStanding<Row>> = [];
5093
+ // NOTE: one retried count read per row, withdrawn and expired rows included, since rows
5094
+ // are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
5095
+ // counts in one statement or skip the count for rows that are no longer open.
5096
+ for (const row of rows) {
5097
+ const usesRecorded = await countUsage(row.key);
5098
+ const withdrawn = withdrawnStamps.has(row.stampId);
5099
+ const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
5100
+ && (row.totalUses === null || usesRecorded < row.totalUses);
5101
+ standings.push({ row, live, withdrawn, usesRecorded });
4426
5102
  }
4427
- return statuses;
5103
+ return standings;
4428
5104
  }
4429
5105
 
4430
5106
  /**
@@ -4447,66 +5123,75 @@ export class ControlDatabase {
4447
5123
  async withdrawCadreInvite(
4448
5124
  key: string,
4449
5125
  ownerKey: string,
4450
- signMessage: (message: Uint8Array) => string
5126
+ signMessage: ControlSign
4451
5127
  ): Promise<boolean> {
5128
+ return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
5129
+ }
5130
+
5131
+ /**
5132
+ * The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
5133
+ * lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
5134
+ * the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
5135
+ */
5136
+ private async withdrawIssuedRow(table: IssuedTable, key: string, ownerKey: string, signMessage: ControlSign): Promise<boolean> {
4452
5137
  this.ensureInitialized();
4453
5138
  const filed = await this.lockedWithRetry(async (): Promise<RevokedRowRef | null> => {
4454
- const [withdrawal] = await this.signCadreInviteWithdrawals([key], signMessage);
5139
+ const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
4455
5140
  if (withdrawal === undefined) {
4456
5141
  return null;
4457
5142
  }
4458
5143
  await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
4459
5144
  return withdrawal.ref;
4460
- }, {}, 'cadre-invite-withdraw');
5145
+ }, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
4461
5146
  if (filed === null) {
4462
5147
  return false;
4463
5148
  }
4464
- log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
5149
+ log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
4465
5150
  this.notifyGuardedDelete(filed);
4466
5151
  return true;
4467
5152
  }
4468
5153
 
4469
5154
  /**
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
5155
+ * The withdrawal tombstones for the `table` rows among `keys` that are held here and not
5156
+ * yet withdrawn, signed and ready for {@link execTombstone}: the body
5157
+ * {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
4473
5158
  * transaction. Inside the locked body (every read `retry: false`); an absent or already
4474
5159
  * withdrawn key is logged and left out.
4475
5160
  */
4476
- private async signCadreInviteWithdrawals(
5161
+ private async signWithdrawals(
5162
+ table: IssuedTable,
4477
5163
  keys: readonly string[],
4478
- signMessage: (message: Uint8Array) => string
5164
+ signMessage: ControlSign
4479
5165
  ): Promise<Array<{ ref: RevokedRowRef; signature: string }>> {
4480
5166
  if (keys.length === 0) {
4481
5167
  return [];
4482
5168
  }
4483
- const withdrawn = await this.queryRevokedStamps('CadreInvite', false);
5169
+ const withdrawn = await this.queryRevokedStamps(table, false);
4484
5170
  const withdrawals: Array<{ ref: RevokedRowRef; signature: string }> = [];
4485
5171
  for (const key of keys) {
4486
- const stampId = await this.queryStampId('CadreInvite', key, false);
5172
+ const stampId = await this.queryStampId(table, key, false);
4487
5173
  if (stampId === null) {
4488
- log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
5174
+ log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
4489
5175
  continue;
4490
5176
  }
4491
5177
  if (withdrawn.has(stampId)) {
4492
- log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
5178
+ log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
4493
5179
  continue;
4494
5180
  }
4495
- const ref: RevokedRowRef = { tableName: 'CadreInvite', rowKey: key, stampId };
4496
- withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
5181
+ const ref: RevokedRowRef = { tableName: table, rowKey: key, stampId };
5182
+ withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
4497
5183
  }
4498
5184
  return withdrawals;
4499
5185
  }
4500
5186
 
4501
5187
  /**
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`.
5188
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
5189
+ * with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
5190
+ * One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
5191
+ * the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
5192
+ * reads the peer row back, so the two are mutually bound to this exact redemption and
5193
+ * neither can be held in reserve. The invitation must already be committed
5194
+ * ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
4510
5195
  *
4511
5196
  * The two signatures are the holder's and the device's, minted on the device over
4512
5197
  * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
@@ -4515,26 +5200,41 @@ export class ControlDatabase {
4515
5200
  * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
4516
5201
  *
4517
5202
  * 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.
5203
+ * nothing is written and no seat is spent — `alreadyMember: true`. The seat check
5204
+ * ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
5205
+ * redemption is answered `alreadyMember` rather than refused as exhausted.
4523
5206
  *
4524
5207
  * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
4525
5208
  * every read passes `retry: false` and every stamp is minted per attempt, so a
4526
5209
  * {@link lockedWithRetry} re-run starts clean.
4527
5210
  *
4528
5211
  * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
4529
- * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
5212
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
5213
+ * {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
4530
5214
  */
4531
5215
  async redeemCadreInvite(params: {
4532
5216
  inviteKey: string;
5217
+ /**
5218
+ * The row this node holds under `inviteKey`, read before the call ({@link queryCadreInvite});
5219
+ * omitted, it is read inside the locked body. Never a bundle's copy: the requester chooses a
5220
+ * copy's `TotalUses` and `IssuerKey`, and the seat check must judge the stored cap. The
5221
+ * schema refuses every update and delete of the row (`NoUpdate`, `NoDelete`), so the earlier
5222
+ * read is the row a locked read would return; withdrawal is a `Revocation` row, which the schema's
5223
+ * `NotRevoked` and `Authorized` checks still read at commit.
5224
+ */
5225
+ held?: CadreInviteRow;
4533
5226
  peerId: string;
4534
5227
  /** The device's ed25519 public key (base64url); `peerId` is its identity multihash. */
4535
5228
  peerKey: string;
4536
5229
  /** The device's dialable addresses, if known; stored comma-joined as `authorizePeer` does. */
4537
5230
  multiaddrs?: string[];
5231
+ /**
5232
+ * The rest of the device's own signed address record over `multiaddrs`, already verified by
5233
+ * the caller (`admittedRecord` in `cadre-invite-protocol.ts`): stored as the row's
5234
+ * `UpdatedAt`, `Sig` and `Profile`, so the row resolves to the device's addresses from its
5235
+ * admission. Omitted, the row is written unsigned, `UpdatedAt` at this machine's clock.
5236
+ */
5237
+ record?: Pick<PeerAddressRecord, 'updatedAt' | 'profile' | 'sig'>;
4538
5238
  /** Single-use nonce minted by the device; both signatures cover it. */
4539
5239
  usageStampId: string;
4540
5240
  /** The holder's signature with the invitation private key over the `'redeem'` digest. */
@@ -4544,72 +5244,48 @@ export class ControlDatabase {
4544
5244
  nowMs?: number;
4545
5245
  }): Promise<CadreInviteRedemptionResult> {
4546
5246
  this.ensureInitialized();
4547
- const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
5247
+ const { inviteKey, peerId, peerKey, record, usageStampId, inviteSig, peerSig } = params;
4548
5248
  const nowMs = params.nowMs ?? Date.now();
4549
5249
  const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
4550
5250
  log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
4551
5251
 
4552
5252
  return this.mutateCadrePeer('cadre-invite-redeem', async () => {
4553
- const invite = await this.queryCadreInvite(inviteKey, false);
5253
+ const invite = params.held ?? await this.queryCadreInvite(inviteKey, false);
4554
5254
  if (invite === null) {
4555
- throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
5255
+ throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
4556
5256
  }
4557
- const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
4558
- if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
5257
+ // A raw stamp read (queryStampId), so a physically present row counts; inside the locked
5258
+ // body, so never retried.
5259
+ const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
5260
+ if (seatedPeerStampId !== null) {
4559
5261
  log('redeemCadreInvite: %s already admitted; nothing written', peerId);
4560
- return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
5262
+ return { alreadyMember: true, peerStampId: seatedPeerStampId };
4561
5263
  }
4562
5264
  await this.assertCadreInviteSeatRemains(invite);
4563
5265
 
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;
5266
+ // The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
5267
+ // names the row it seats. Minted inside the body, so a re-run mints afresh over a
5268
+ // rolled-back attempt rather than re-presenting a stamp the usage row may already name.
5269
+ const peerStampId = generateStampId(peerId);
4569
5270
  const nowCanonical = await canonicalDatetime(this.db!, nowMs);
4570
5271
  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
5272
  await this.db!.exec(`
4586
- insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
5273
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig, VouchUsage)
5274
+ with context OwnerKey = null, Signature = null
5275
+ values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)
5276
+ `, [peerId, peerKey, multiaddr, record?.updatedAt ?? nowMs, record?.sig ?? null, record?.profile ?? null,
5277
+ peerStampId, invite.issuerKey, usageStampId]);
5278
+ await this.db!.exec(`
5279
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
4587
5280
  with context Now = ?
4588
- values (?, ?, ?, ?, ?, ?, ?, ?)
4589
- `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
5281
+ values (?, ?, ?, ?, ?, ?, ?)
5282
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
4590
5283
  });
4591
- log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
4592
- return { alreadyMember: false, peerStampId, ownerStampId };
5284
+ log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
5285
+ return { alreadyMember: false, peerStampId };
4593
5286
  });
4594
5287
  }
4595
5288
 
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
5289
  /**
4614
5290
  * Refuse a redemption that would consume a seat the cadre invitation does not have, by
4615
5291
  * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
@@ -4617,20 +5293,267 @@ export class ControlDatabase {
4617
5293
  * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
4618
5294
  * committed count inside the locked body, so on a same-node race the loser is refused here.
4619
5295
  */
4620
- private async assertCadreInviteSeatRemains(invite: CadreInviteRow): Promise<void> {
4621
- if (invite.totalUses === null) {
5296
+ private assertCadreInviteSeatRemains(invite: CadreInviteRow): Promise<void> {
5297
+ return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
5298
+ }
5299
+
5300
+ /** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
5301
+ private async assertIssuedSeatRemains(row: { key: string; totalUses: number | null }, countUsage: (key: string) => Promise<number>): Promise<void> {
5302
+ if (row.totalUses === null) {
4622
5303
  return;
4623
5304
  }
4624
- const used = await this.countCadreInviteUsage(invite.key, false);
4625
- if (used >= invite.totalUses) {
4626
- throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
5305
+ const used = await countUsage(row.key);
5306
+ if (used >= row.totalUses) {
5307
+ throw new InvitationExhaustedError(row.key, used, row.totalUses);
5308
+ }
5309
+ }
5310
+
5311
+ // ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
5312
+
5313
+ /**
5314
+ * Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
5315
+ * grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
5316
+ * to seat an owner key the holder generates or already holds. No machine is admitted and no
5317
+ * peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
5318
+ * row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
5319
+ * the returned row is what the grant bundle carries, re-inserted verbatim by
5320
+ * {@link seatOwnerGrant} on a member that has not received it by replication.
5321
+ *
5322
+ * @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
5323
+ * caller minted the pair and hands the private half to the holder out of band
5324
+ * @param grant.label - the label every key this grant seats will carry
5325
+ * ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
5326
+ * issuer's choice, which the holder cannot change
5327
+ * @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
5328
+ * never. The short default lifetime is the node API's to apply, not this writer's.
5329
+ * @param grant.totalUses - how many redemptions it allows; omit for unlimited
5330
+ * @returns the row as stored, `expiresAt` in its canonical form
5331
+ */
5332
+ async insertOwnerGrant(
5333
+ grant: { key: string; label?: string | null; expiresAtMs?: number | null; totalUses?: number | null },
5334
+ ownerKey: string,
5335
+ signMessage: ControlSign
5336
+ ): Promise<OwnerGrantRow> {
5337
+ this.ensureInitialized();
5338
+ const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
5339
+ const signed: OwnerGrantSignedFields = {
5340
+ key,
5341
+ label: ownerKeyLabel(grant.label),
5342
+ expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db!, grant.expiresAtMs),
5343
+ totalUses: grant.totalUses ?? null,
5344
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
5345
+ };
5346
+ const row: OwnerGrantRow = {
5347
+ ...signed,
5348
+ issuerKey: ownerKey,
5349
+ issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
5350
+ };
5351
+ await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
5352
+ log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
5353
+ return row;
5354
+ }
5355
+
5356
+ /**
5357
+ * Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
5358
+ * `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
5359
+ *
5360
+ * @returns `true` when this call inserted the row, `false` when it was already present.
5361
+ * @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
5362
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
5363
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
5364
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
5365
+ */
5366
+ async seatOwnerGrant(row: OwnerGrantRow): Promise<boolean> {
5367
+ return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row),
5368
+ cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
5369
+ }
5370
+
5371
+ /** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
5372
+ private async execOwnerGrantInsert(row: OwnerGrantRow): Promise<void> {
5373
+ await this.db!.exec(`
5374
+ insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
5375
+ with context OwnerKey = ?, Signature = ?
5376
+ values (?, ?, ?, ?, ?, ?, ?)
5377
+ `, [
5378
+ row.issuerKey, row.issuerSig,
5379
+ row.key, row.label, row.expiresAt, row.totalUses,
5380
+ row.issuerKey, row.issuerSig, row.stampId,
5381
+ ]);
5382
+ }
5383
+
5384
+ /**
5385
+ * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
5386
+ * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
5387
+ * away. Read once per redemption and carried into {@link isOwnerGrantLive} and
5388
+ * {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
5389
+ * handed none and reads inside its locked body.
5390
+ */
5391
+ async queryOwnerGrant(key: string, retry = true): Promise<OwnerGrantRow | null> {
5392
+ this.ensureInitialized();
5393
+ for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
5394
+ return ownerGrantRowOf(row);
4627
5395
  }
5396
+ return null;
5397
+ }
5398
+
5399
+ /**
5400
+ * Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
5401
+ * anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
5402
+ * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
5403
+ */
5404
+ async queryOwnerGrants(retry = true): Promise<OwnerGrantRow[]> {
5405
+ return (await this.readMembershipRows(this.ownerGrantView, retry)).map(ownerGrantRowOf);
5406
+ }
5407
+
5408
+ /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}; both come from their membership views ({@link readMembershipRows}). */
5409
+ async queryOwnerGrantUsages(retry = true): Promise<OwnerGrantUsageRow[]> {
5410
+ return (await this.readMembershipRows(this.ownerGrantUsageView, retry)).map(ownerGrantUsageRowOf);
5411
+ }
5412
+
5413
+ /**
5414
+ * How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
5415
+ * through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
5416
+ * {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
5417
+ */
5418
+ async countOwnerGrantUsage(key: string, retry = true): Promise<number> {
5419
+ return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
5420
+ }
5421
+
5422
+ /**
5423
+ * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
5424
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
5425
+ * this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
5426
+ * `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
5427
+ * permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
5428
+ * condition. Its use count is read again inside the write lock, for
5429
+ * {@link isCadreInviteLive}'s reason.
5430
+ */
5431
+ async isOwnerGrantLive(grant: OwnerGrantRow, held: OwnerGrantRow | null, nowMs: number = Date.now()): Promise<boolean> {
5432
+ this.ensureInitialized();
5433
+ const row = held ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
5434
+ return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
5435
+ }
5436
+
5437
+ /**
5438
+ * Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
5439
+ * and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
5440
+ */
5441
+ async listOwnerGrantStatuses(nowMs: number = Date.now()): Promise<OwnerGrantStatus[]> {
5442
+ const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
5443
+ return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
5444
+ }
5445
+
5446
+ /**
5447
+ * Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
5448
+ * stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
5449
+ * and for the same reason (every key the grant seated proves itself through the row;
5450
+ * `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
5451
+ * refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
5452
+ * holder's copy on any node holding the tombstone. Keys already seated stay owners;
5453
+ * removing one is {@link deleteOwnerKey}.
5454
+ *
5455
+ * Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
5456
+ * `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
5457
+ * withdrawal committed while alone is re-issued on cohort growth.
5458
+ */
5459
+ async withdrawOwnerGrant(key: string, ownerKey: string, signMessage: ControlSign): Promise<boolean> {
5460
+ return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
5461
+ }
5462
+
5463
+ /**
5464
+ * Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
5465
+ * owner signature. One transaction writes the `OwnerKey` row (the grant branch of
5466
+ * `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
5467
+ * `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
5468
+ * row's `SeatedExists` reads the owner row back, so the two are bound to this exact
5469
+ * redemption and neither can be held in reserve. The grant must already be committed
5470
+ * ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
5471
+ * admitted and no peer id is named.
5472
+ *
5473
+ * The two signatures are minted where the private keys are (the grant's and the seated
5474
+ * key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
5475
+ * machine only verifies them, by writing the row.
5476
+ *
5477
+ * Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
5478
+ * `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
5479
+ * that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
5480
+ * the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
5481
+ * exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
5482
+ * except on a machine that still holds its retired row, which refuses by name
5483
+ * ({@link liveOwnerKeyStamp}).
5484
+ *
5485
+ * Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
5486
+ * inside the locked body every read passes `retry: false` and the stamp is minted per
5487
+ * attempt, so a {@link lockedWithRetry} re-run starts clean.
5488
+ *
5489
+ * @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
5490
+ * the schema's generic `Authorized` refusal); its `token` is the grant key.
5491
+ * {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
5492
+ */
5493
+ async redeemOwnerGrant(params: {
5494
+ grantKey: string;
5495
+ /** The row this node holds under `grantKey`, read before the call; omitted, it is read inside the locked body. Never a bundle's copy, for {@link redeemCadreInvite}'s reason. */
5496
+ held?: OwnerGrantRow;
5497
+ /** The owner key to seat (base64url ed25519): the holder generated it, or already holds it. */
5498
+ seatedKey: string;
5499
+ /** Single-use nonce minted by the holder; both signatures cover it. */
5500
+ usageStampId: string;
5501
+ /** The holder's signature with the grant private key over the `'redeem'` digest. */
5502
+ grantSig: string;
5503
+ /** The seated key's signature over the `'consent'` digest. */
5504
+ ownerSig: string;
5505
+ nowMs?: number;
5506
+ }): Promise<OwnerGrantRedemptionResult> {
5507
+ this.ensureInitialized();
5508
+ const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
5509
+ const nowMs = params.nowMs ?? Date.now();
5510
+ log('Redeeming owner grant %s for %s', grantKey, seatedKey);
5511
+
5512
+ return this.mutateCadrePeer('owner-grant-redeem', async () => {
5513
+ const grant = params.held ?? await this.queryOwnerGrant(grantKey, false);
5514
+ if (grant === null) {
5515
+ throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
5516
+ }
5517
+ const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
5518
+ if (heldStamp !== null) {
5519
+ log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
5520
+ return { alreadyOwner: true, seatedStampId: heldStamp };
5521
+ }
5522
+ await this.assertOwnerGrantSeatRemains(grant);
5523
+
5524
+ // Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
5525
+ // than re-presenting a stamp the usage row may already name.
5526
+ const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
5527
+ const nowCanonical = await canonicalDatetime(this.db!, nowMs);
5528
+ await this.inTransaction('owner-grant-redeem', async () => {
5529
+ await this.db!.exec(`
5530
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
5531
+ with context OwnerKey = null, Signature = null
5532
+ values (?, ?, ?, ?, null, ?)
5533
+ `, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
5534
+ await this.db!.exec(`
5535
+ insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
5536
+ with context Now = ?
5537
+ values (?, ?, ?, ?, ?, ?)
5538
+ `, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
5539
+ });
5540
+ log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
5541
+ return { alreadyOwner: false, seatedStampId };
5542
+ });
5543
+ }
5544
+
5545
+ /** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
5546
+ private assertOwnerGrantSeatRemains(grant: OwnerGrantRow): Promise<void> {
5547
+ return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
4628
5548
  }
4629
5549
 
4630
5550
  /**
4631
5551
  * Close the database and cleanup resources.
4632
5552
  *
4633
- * Drains the local-write chain first: {@link withWriteLock} can park a write behind
5553
+ * Disposes every {@link watchTables} subscription before anything else, so a commit that
5554
+ * lands while the write chain drains schedules no run, and again before dropping the handle.
5555
+ *
5556
+ * Then drains the local-write chain: {@link withWriteLock} can park a write behind
4634
5557
  * others across an await, and a queued closure evaluates `this.db!` only when it
4635
5558
  * finally runs — so nulling the handle out from under it would throw a TypeError on a
4636
5559
  * null handle instead of committing. The tail never rejects (it swallows both
@@ -4641,11 +5564,17 @@ export class ControlDatabase {
4641
5564
  * deadline) if a write can ever hang.
4642
5565
  */
4643
5566
  async close(): Promise<void> {
5567
+ this.tableWatches.disposeAll();
4644
5568
  await this.writeQueue;
4645
5569
  if (this.collectionFactory) {
4646
5570
  await this.collectionFactory.shutdown();
4647
5571
  this.collectionFactory = null;
4648
5572
  }
5573
+ this.txnBridge = null;
5574
+ // Again: a subscription taken during the awaits above would keep its timers past close.
5575
+ this.tableWatches.disposeAll();
5576
+ // Here, not first: a read started during the awaits above must not install either.
5577
+ this.invalidateMembershipViews();
4649
5578
  if (this.db) {
4650
5579
  void this.db.close();
4651
5580
  this.db = null;