@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
@@ -4,14 +4,19 @@ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlu
4
4
  import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
5
5
  import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
6
6
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
7
- import { CONTROL_SCHEMA } from './control-schema.js';
7
+ import { createEmptyCollections } from '@serfab/quereus-plugin-sereus';
8
+ import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, CONTROL_SCHEMA_NAME, controlSchemaFileDeclaration } from './control-schema.js';
9
+ import { refuseIncompatibleControlStore } from './control-schema-version.js';
8
10
  import { canonicalDatetime } from './canonical-datetime.js';
9
- import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, CONTROL_TABLES } from './control-authorization.js';
10
- import { ed25519PublicKeyB64FromPeerId, requireEd25519PublicKeyB64 } from './ed25519-key.js';
11
- import { verifyCadreInviteRow } from './peer-authorization.js';
12
- import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
11
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
12
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
13
+ import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
14
+ import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
15
+ import { retryControlWrite, LOST_UPDATE_RETRY_POLICY, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
13
16
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
14
17
  import { chainMessages } from './control-retry.js';
18
+ import { TableWatchRegistry } from './table-change-watch.js';
19
+ import { MembershipView } from './membership-view.js';
15
20
  const log = debug('sereus:cadre:control-db');
16
21
  const timing = debug('sereus:cadre:timing');
17
22
  /**
@@ -40,12 +45,11 @@ export function generateStampId(peerId) {
40
45
  * present locally.
41
46
  *
42
47
  * Consent is bound to the strand's one-off `StampId` (see the consent branch of
43
- * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
44
- * The ordinary "host strand has not converged on this responder yet" case is caught
45
- * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
46
- * reaching here means the row vanished between that check and the write, which is a
47
- * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
48
- * CHECK at commit (which would drop the write with a far less legible error).
48
+ * `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
49
+ * inserting, and throws this when the row is absent rather than leaving it to fail the
50
+ * deferred `StrandExists` CHECK at commit (a far less legible error). The formation
51
+ * responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
52
+ * reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
49
53
  */
50
54
  export class MissingHostStrandError extends Error {
51
55
  constructor(strandId, token) {
@@ -78,22 +82,23 @@ export class FormationAbortedError extends Error {
78
82
  /**
79
83
  * The invitation's seat budget is already spent: the count of recorded usage rows for the
80
84
  * invitation has reached its `TotalUses`, so this redemption cannot be given a seat. `token`
81
- * is the `FormationInvite.Token` or the `CadreInvite.Key`, whichever table the caller
82
- * redeemed against.
85
+ * is the `FormationInvite.Token`, the `CadreInvite.Key` or the `OwnerGrant.Key`, whichever
86
+ * table the caller redeemed against.
83
87
  *
84
88
  * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
85
89
  * the write, inside {@link ControlDatabase.redeemInvitation} /
86
90
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
87
- * ({@link ControlDatabase.redeemCadreInvite}), and off the unlocked pre-check
88
- * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
89
- * pass — and off nothing else. It exists because
91
+ * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
92
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
93
+ * `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
94
+ * manager issues a membership pass — and off nothing else. It exists because
90
95
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
91
96
  * schema's own count-based cap clause, which the manager reports as a retryable
92
97
  * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
93
98
  * Two redemptions of one token racing on the SAME node serialize behind the local write
94
- * queue, so the loser reads the winner's committed row and is refused here by name —
95
- * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
96
- * ran before either redemption wrote anything.
99
+ * queue, so the loser reads the winner's committed row and is refused here by name — the
100
+ * use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
101
+ * read before either redemption wrote anything.
97
102
  */
98
103
  export class InvitationExhaustedError extends Error {
99
104
  constructor(token, usesRecorded, totalUses) {
@@ -119,8 +124,42 @@ export class CadreInviteIssuerUnknownError extends Error {
119
124
  this.name = 'CadreInviteIssuerUnknownError';
120
125
  }
121
126
  }
122
- /** The refusal {@link CadreInviteIssuerUnknownError} wraps — the constraint named, as the engine words it. */
123
- const CADRE_INVITE_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
127
+ /**
128
+ * {@link ControlDatabase.seatOwnerGrant} was refused by `OwnerGrant.AuthorizedInsert`: the
129
+ * grant twin of {@link CadreInviteIssuerUnknownError}, for the same two causes (the issuer's
130
+ * `OwnerKey` row has not replicated here yet or has since been removed; or a tampered row).
131
+ * Its own class rather than a shared one so a caller that handles invitation and grant
132
+ * bundles can tell by `instanceof` which credential was refused.
133
+ */
134
+ export class OwnerGrantIssuerUnknownError extends Error {
135
+ constructor(grantKey, issuerKey, options) {
136
+ super(`Owner grant ${grantKey} cannot be seated here: its issuer ${issuerKey} is not an owner on this node, or its signature does not verify`, options);
137
+ this.grantKey = grantKey;
138
+ this.issuerKey = issuerKey;
139
+ this.name = 'OwnerGrantIssuerUnknownError';
140
+ }
141
+ }
142
+ /**
143
+ * An issued row (a `CadreInvite` or an `OwnerGrant`) the cadre holds that this node cannot read
144
+ * yet: {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} had its
145
+ * insert refused by a cohort member already holding the row, and a refreshed read here still
146
+ * finds nothing; or the redemption that follows the seat finds no row. It clears once this
147
+ * node's store catches up, which is why the redemption paths answer it with the retryable
148
+ * `conflict` code.
149
+ */
150
+ export class IssuedRowNotHereYetError extends Error {
151
+ constructor(table, key, options) {
152
+ super(`${table} ${key} is held by the cadre but not readable on this node yet; try again once it has synced`, options);
153
+ this.table = table;
154
+ this.key = key;
155
+ this.name = 'IssuedRowNotHereYetError';
156
+ }
157
+ }
158
+ /**
159
+ * The refusal {@link CadreInviteIssuerUnknownError} and {@link OwnerGrantIssuerUnknownError}
160
+ * wrap — the constraint named, as the engine words it; both tables name theirs `AuthorizedInsert`.
161
+ */
162
+ const ISSUER_UNAUTHORIZED = /CHECK constraint failed: AuthorizedInsert\b/;
124
163
  /**
125
164
  * Parse a stored Quereus `datetime` value into epoch milliseconds.
126
165
  *
@@ -288,6 +327,39 @@ export function cadreInviteConsentMessage(partyId, fields) {
288
327
  fields.inviteKey, fields.usageStampId, fields.peerKey,
289
328
  ]);
290
329
  }
330
+ /**
331
+ * The exact bytes an owner signs to seat an `OwnerGrant` row — the TS mirror of the `'add'`
332
+ * digest in `OwnerGrant.AuthorizedInsert`, over the whole row in the schema's order with
333
+ * nullable columns as `''` (`ownerGrantRowFields`, shared with the base64url verifier
334
+ * `peer-authorization.ts`'s `ownerGrantAddDigest`). `expiresAt` must already be the stored
335
+ * canonical `datetime` string, as {@link ControlDatabase.insertOwnerGrant} produces it, for
336
+ * the reason on {@link cadreInviteAddMessage}.
337
+ */
338
+ export function ownerGrantAddMessage(partyId, row) {
339
+ return buildAuthorizationMessage('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
340
+ }
341
+ /**
342
+ * The exact bytes the HOLDER of an owner grant signs, with the grant's private key, to redeem
343
+ * it once — the TS mirror of the `'redeem'` digest `OwnerGrantUsage.GrantPossessed` verifies
344
+ * against the grant's public key. Binding the seated key and the nonce means a captured
345
+ * redemption can seat no other key and spend no other use.
346
+ */
347
+ export function ownerGrantRedeemMessage(partyId, fields) {
348
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'redeem', partyId, [
349
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
350
+ ]);
351
+ }
352
+ /**
353
+ * The exact bytes the SEATED key signs, proving the holder holds it — the TS mirror of the
354
+ * `'consent'` digest `OwnerGrantUsage.OwnerConsented` verifies against `SeatedKey`. Same
355
+ * fields as {@link ownerGrantRedeemMessage} under a distinct action tag, so neither
356
+ * signature can stand in for the other.
357
+ */
358
+ export function ownerGrantConsentMessage(partyId, fields) {
359
+ return buildAuthorizationMessage('CadreControl.OwnerGrantUsage', 'consent', partyId, [
360
+ fields.grantKey, fields.usageStampId, fields.seatedKey,
361
+ ]);
362
+ }
291
363
  /** Runtime guard for the dynamic-`from` count, over the one table list. */
292
364
  const CONTROL_TABLE_SET = new Set(CONTROL_TABLES);
293
365
  /**
@@ -306,13 +378,15 @@ const GUARDED_KEY_COLUMN = {
306
378
  JoinRequest: 'Id',
307
379
  DeviceToken: 'PeerId',
308
380
  CadreInvite: 'Key',
381
+ OwnerGrant: 'Key',
309
382
  };
383
+ /** The write-reason prefix each {@link IssuedTable}'s seat and withdrawal log under. */
384
+ const ISSUED_TABLE_REASON = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
310
385
  /**
311
386
  * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
312
- * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
313
- * tombstone writer (a guarded removal, a `CadreInvite` withdrawal, the ledger marker) so the
314
- * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
315
- * `revocationDigest`.
387
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by both
388
+ * tombstone writers (a guarded removal, an issued row's withdrawal) so the field order lives
389
+ * in one place; the base64url verifier is `peer-authorization.ts`'s `revocationDigest`.
316
390
  */
317
391
  function revocationTombstoneMessage(partyId, ref) {
318
392
  return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
@@ -321,28 +395,46 @@ function revocationTombstoneMessage(partyId, ref) {
321
395
  * The exact bytes an owner signs to seat a further `OwnerKey` row (the signed-add branch of
322
396
  * `OwnerKey.Authorized`); the base64url twin is `peer-authorization.ts`'s `ownerKeyAddDigest`.
323
397
  */
324
- function ownerKeyAddMessage(partyId, key, stampId) {
325
- return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
398
+ function ownerKeyAddMessage(partyId, key, label, stampId) {
399
+ return buildAuthorizationMessage('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
326
400
  }
327
- function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
401
+ /**
402
+ * An owner key's label as its row stores it: trimmed, a blank one left out, and refused past
403
+ * {@link OWNER_KEY_MAX_LABEL} by name rather than by the schema's `LabelLength` check.
404
+ * Idempotent, so a caller that checks before opening an owner approval can pass the result on.
405
+ */
406
+ export function ownerKeyLabel(label) {
407
+ const trimmed = label?.trim();
408
+ if (!trimmed) {
409
+ return null;
410
+ }
411
+ if (trimmed.length > OWNER_KEY_MAX_LABEL) {
412
+ throw new Error(`An owner key's label must be at most ${OWNER_KEY_MAX_LABEL} characters (this one has ${trimmed.length})`);
413
+ }
414
+ return trimmed;
415
+ }
416
+ async function signGuardedRemoval(partyId, tableName, rowKey, stampId, signMessage) {
328
417
  const ref = { tableName, rowKey, stampId };
329
418
  return {
330
419
  ref,
331
- signature: signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
332
- revocationSignature: signMessage(revocationTombstoneMessage(partyId, ref)),
420
+ signature: await signMessage(buildAuthorizationMessage(`CadreControl.${tableName}`, 'remove', partyId, [rowKey, stampId])),
421
+ revocationSignature: await signMessage(revocationTombstoneMessage(partyId, ref)),
333
422
  };
334
423
  }
335
424
  /**
336
- * The rejection a second insert of an already-seated id into `table` produces, as the
425
+ * The rejection a second insert of an already-seated key into `table` produces, as the
337
426
  * optimystic vtab words it (`uniqueConstraintMessage`, qualified by table name only — no
338
- * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
427
+ * schema prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`),
428
+ * naming the table's {@link GUARDED_KEY_COLUMN}.
339
429
  *
340
430
  * Each table's `StampId` is unique too, but a fresh stamp is minted per insert attempt, so
341
- * only the primary key can collide on a repeat insert. Matching the column explicitly
342
- * keeps any OTHER uniqueness failure out of the caller's idempotency branch.
431
+ * only the primary key can collide on a repeat insert. An issued row's bundle copy repeats its
432
+ * stamp as well, and the engine still names the key (`control-owner-grant.spec.ts` pins it).
433
+ * Matching the column explicitly keeps any OTHER uniqueness failure out of the caller's
434
+ * idempotency branch.
343
435
  */
344
- function idConflictPattern(table) {
345
- return new RegExp(`UNIQUE constraint failed: ${table}\\.Id\\b`, 'i');
436
+ function keyConflictPattern(table) {
437
+ return new RegExp(`UNIQUE constraint failed: ${table}\\.${GUARDED_KEY_COLUMN[table]}\\b`, 'i');
346
438
  }
347
439
  /**
348
440
  * Did this write to `table` fail because the strand id is already seated there?
@@ -350,7 +442,8 @@ function idConflictPattern(table) {
350
442
  * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
351
443
  * landed" (re-read, and no-op when the row matches) apart from every other rejection —
352
444
  * an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
353
- * the same test tells "another machine of this party already published this join" apart.
445
+ * the same test tells "another machine of this party already published this join" apart, and
446
+ * for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
354
447
  *
355
448
  * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
356
449
  * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
@@ -360,7 +453,16 @@ function idConflictPattern(table) {
360
453
  * reword reddens there.
361
454
  */
362
455
  export function isStrandIdConflict(error, table) {
363
- return errorChainMatches(error, idConflictPattern(table));
456
+ return errorChainMatches(error, keyConflictPattern(table));
457
+ }
458
+ /**
459
+ * Did a seat of an issued row fail because a cohort member already holds a row under its key?
460
+ * {@link ControlDatabase.seatCadreInvite} / {@link ControlDatabase.seatOwnerGrant} reach this
461
+ * on a node whose store lags the cohort: its guard read missed the row, and the member that
462
+ * checked the commit refused the insert. Text-matched, failing closed, like {@link isStrandIdConflict}.
463
+ */
464
+ export function isIssuedRowKeyConflict(error, table) {
465
+ return errorChainMatches(error, keyConflictPattern(table));
364
466
  }
365
467
  /**
366
468
  * Did {@link ControlDatabase.insertJoinRequest} fail because the party already holds a
@@ -372,7 +474,7 @@ export function isStrandIdConflict(error, table) {
372
474
  * until the next reap pass.
373
475
  */
374
476
  export function isPendingJoinConflict(error) {
375
- return errorChainMatches(error, idConflictPattern('JoinRequest'));
477
+ return errorChainMatches(error, keyConflictPattern('JoinRequest'));
376
478
  }
377
479
  /**
378
480
  * The `JoinRequest.Id` of an invitation: the base64url sha256 `digest` of its token. One row
@@ -396,69 +498,48 @@ export class PendingJoinChangedError extends Error {
396
498
  }
397
499
  }
398
500
  /**
399
- * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
400
- * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
401
- * block. The schema's table comment says why that matters and why the row can never read
402
- * as a retirement; `RowIsGone` pins this exact triple.
501
+ * A `CadreControl.Revocation` row that retires nothing, which parties founded before
502
+ * `control-collections-created-at-cadre-founding` may hold: an owner filed it so the table
503
+ * had a collection, which genesis now creates. Nothing files it now. `RowIsGone` still admits
504
+ * this exact triple, and {@link ControlDatabase.queryRevocations} skips it.
403
505
  */
404
- export const REVOCATION_LEDGER_MARKER = {
506
+ const REVOCATION_LEDGER_MARKER = {
405
507
  tableName: 'Revocation',
406
508
  rowKey: 'ledger',
407
509
  stampId: 'opened',
408
510
  };
409
- /**
410
- * The rejection a second insert of the ledger marker produces: the optimystic vtab's
411
- * primary-key wording (see {@link idConflictPattern}), naming both columns of
412
- * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
413
- * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
414
- * has no other unique constraint.
415
- */
416
- const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
417
- /** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
418
- function isRevocationLedgerConflict(error) {
419
- return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
420
- }
421
511
  /** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
422
512
  function errorChainMatches(error, pattern) {
423
513
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
424
514
  }
425
515
  /**
426
516
  * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
427
- * redemption reply, carries.
517
+ * redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
518
+ * redemption; it contributes an entry only through its own `CadrePeer` row.
428
519
  *
429
- * Owner identity is sourced from the `OwnerKey` table, not from the transport peer id. An
430
- * Ed25519 libp2p peer id embeds its public key (identity multihash), so each peer's ed25519
431
- * key is derivable from its `PeerId`; a peer is an owner iff that derived key is in
432
- * `ownerKeys`. This makes any owner node markable — not just the local one. A non-Ed25519
433
- * or unparsable id yields a non-owner rather than failing the whole projection.
434
- *
435
- * NOTE: this is the one owner lookup deliberately left on the REPLICATED table rather than
436
- * the node-local anchor. `SeedPeer.isOwner` is a dial hint — the receiver dials owner-flagged
437
- * peers first — not a trust decision, and the receiver re-derives real trust from its own
438
- * anchor. So a polluted table costs at most a wasted dial, while anchoring here would
439
- * silently drop legitimate co-owners this node never pinned. If `isOwner` ever gates
440
- * anything the receiver TRUSTS, move it to the anchor.
520
+ * A peer is `preferred` when it is the minter, the one machine certain to be reachable by the
521
+ * receiver at that moment, since it is delivering the seed or the reply, or an always-on
522
+ * machine (`Profile = 'storage'`), the ones that stay dialable. Preference is a dial hint and
523
+ * reads no `OwnerKey` row: owner keys are not machines, and a receiver derives trust from its
524
+ * own anchor, never from this list. A `Profile` is self-declared and not checked here, so a
525
+ * machine misdeclaring one costs the receiver a wasted dial at most.
441
526
  */
442
- export function projectSeedPeers(rows, ownerKeys) {
443
- return rows.map(({ peerId, multiaddr }) => {
444
- const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
445
- const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
446
- return {
447
- peerId,
448
- multiaddrs: multiaddr ? multiaddr.split(',') : [],
449
- isOwner,
450
- ...(isOwner ? { publicKey: pubKeyB64 } : {}),
451
- };
452
- });
527
+ export function projectSeedPeers(rows, minterPeerId) {
528
+ return rows.map(({ peerId, multiaddr, profile }) => ({
529
+ peerId,
530
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
531
+ preferred: peerId === minterPeerId || profile === 'storage',
532
+ }));
453
533
  }
454
534
  /**
455
- * The conditions a cadre invitation must still meet to be redeemed, short of its use count:
456
- * not withdrawn, unexpired (`expiresAt <= now` is expired, as the redemption refuses it),
457
- * and issued by a current owner. Shared by {@link ControlDatabase.hasLiveCadreInvite},
458
- * {@link ControlDatabase.isCadreInviteLive} and {@link ControlDatabase.listCadreInviteStatuses}
459
- * so they cannot drift from each other, or from `CadreInviteUsage.Authorized`.
535
+ * The conditions an issued credential (a cadre invitation or an owner grant) must still meet
536
+ * to be redeemed, short of its use count: not withdrawn, unexpired (`expiresAt <= now` is
537
+ * expired, as the redemption refuses it), and issued by a current owner. Shared by
538
+ * {@link ControlDatabase.hasLiveCadreInvite}, {@link ControlDatabase.isCadreInviteLive},
539
+ * {@link ControlDatabase.listCadreInviteStatuses} and their owner-grant twins so they cannot
540
+ * drift from each other, or from `CadreInviteUsage.Authorized` / `OwnerGrantUsage.Authorized`.
460
541
  */
461
- function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
542
+ function issuedRowStillOpen(row, withdrawn, owners, nowMs) {
462
543
  if (withdrawn.has(row.stampId) || !owners.has(row.issuerKey)) {
463
544
  return false;
464
545
  }
@@ -476,31 +557,97 @@ function cadreInviteStillOpen(row, withdrawn, owners, nowMs) {
476
557
  * hold, so a reaped row is recoverable by re-forming. `JoinRequest` is reapable for the same
477
558
  * reason: the inviting party and the user also hold its invitation. `OwnerKey` has no
478
559
  * production removal path and `MinOneOwner` makes an automated owner-key reap a
479
- * party-bricking hazard. `CadreInvite` is never deleted at all (its tombstone is a
480
- * withdrawal over a row that stays), so there is nothing to reap.
560
+ * party-bricking hazard. `CadreInvite` and `OwnerGrant` are never deleted at all (their
561
+ * tombstones are withdrawals over rows that stay), so there is nothing to reap.
481
562
  */
482
563
  export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'JoinedStrand', 'JoinRequest'];
483
564
  /**
484
565
  * Runtime membership test for {@link REAPABLE_TABLES}, narrowing a tombstone's
485
566
  * `TableName` to the subset {@link ControlDatabase.reapRevokedRow} accepts. A tombstone
486
- * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`) is skipped silently —
567
+ * naming an excluded table (`Strand`, `OwnerKey`, `CadreInvite`, `OwnerGrant`) is skipped silently —
487
568
  * `Strand` tombstones are the common case in a party that has unpublished a strand, so a
488
569
  * log line per skip would be per-pass noise.
489
570
  */
490
571
  const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
491
572
  const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
492
- /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
493
- const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
573
+ /**
574
+ * The full-table scans the `Revocation`, `CadrePeer` and `OwnerKey` membership views load
575
+ * ({@link ControlDatabase.readMembershipRows}); the four chain tables' scans sit beside their
576
+ * row mappers below. Specs rig these reads by their text: `from CadreControl.Revocation`, and
577
+ * the `CadrePeer` scan by its `select PeerId, Multiaddr, StampId` prefix, which no other
578
+ * `CadrePeer` statement shares.
579
+ */
580
+ const REVOCATION_SELECT_SQL = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
581
+ const CADRE_PEER_SELECT_SQL = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
582
+ const OWNER_KEY_SELECT_SQL = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
583
+ /** A stored `CadrePeer` row as a {@link CadrePeerRow}. */
584
+ function cadrePeerRowOf(row) {
585
+ return {
586
+ peerId: row.PeerId,
587
+ publicKey: row.PublicKey ?? null,
588
+ multiaddr: row.Multiaddr ?? null,
589
+ updatedAt: row.UpdatedAt ?? 0,
590
+ profile: row.Profile ?? null,
591
+ sig: row.Sig ?? null,
592
+ stampId: row.StampId ?? null,
593
+ vouchOwner: row.VouchOwner ?? null,
594
+ vouchSig: row.VouchSig ?? null,
595
+ vouchUsage: row.VouchUsage ?? null,
596
+ };
597
+ }
598
+ /** A {@link CadrePeerRow} as the address record its machine signed ({@link PeerAddressRecord}); see {@link ControlDatabase.queryPeerRecord} for the coalescing. */
599
+ function peerAddressRecordOf(row) {
600
+ const multiaddr = row.multiaddr ?? '';
601
+ return {
602
+ peerId: row.peerId,
603
+ publicKey: row.publicKey ?? '',
604
+ addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
605
+ updatedAt: row.updatedAt,
606
+ profile: row.profile,
607
+ sig: row.sig ?? '',
608
+ };
609
+ }
610
+ /**
611
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s decision over the row as read: the row to write,
612
+ * or the {@link CadrePeerRetouchOutcome} that says why nothing is written — no readable row (a
613
+ * stampless row counts, since the voucher digest binds the stamp), or a self-published row in
614
+ * `'rebroadcast'` mode. One function so the pre-check outside the lock and the re-check inside it
615
+ * cannot disagree.
616
+ */
617
+ function retouchTarget(row, mode) {
618
+ if (row === null || row.stampId === null) {
619
+ return 'absent';
620
+ }
621
+ if (mode === 'rebroadcast' && row.sig !== null) {
622
+ return 'self-published';
623
+ }
624
+ return { stampId: row.stampId, updatedAt: row.updatedAt };
625
+ }
626
+ /** A stored `Revocation` row as a {@link RevocationRow}; the caller has already skipped {@link REVOCATION_LEDGER_MARKER}. */
627
+ function revocationRowOf(row) {
628
+ return {
629
+ tableName: row.TableName,
630
+ rowKey: row.RowKey,
631
+ stampId: row.StampId,
632
+ reissuedAt: row.ReissuedAt ?? 0,
633
+ signerKey: row.SignerKey,
634
+ signerSig: row.SignerSig,
635
+ };
636
+ }
637
+ /**
638
+ * Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them: the full scan the
639
+ * `CadreInvite` membership view loads, and the base the point reads append their `where` to.
640
+ */
641
+ const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
494
642
  /**
495
643
  * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
496
644
  * engine-canonical string it reads back as — the form the `'add'` signature covers — never
497
- * parsed to ms here; `GrantsOwner` is the schema's 0/1 integer.
645
+ * parsed to ms here.
498
646
  */
499
647
  function cadreInviteRowOf(row) {
500
648
  return {
501
649
  key: row.Key,
502
650
  peerId: row.PeerId ?? null,
503
- grantsOwner: Number(row.GrantsOwner) === 1,
504
651
  expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
505
652
  totalUses: row.TotalUses ?? null,
506
653
  issuerKey: row.IssuerKey,
@@ -508,6 +655,8 @@ function cadreInviteRowOf(row) {
508
655
  stampId: row.StampId,
509
656
  };
510
657
  }
658
+ /** The full scan the `CadreInviteUsage` membership view loads, in the order {@link cadreInviteUsageRowOf} reads it. */
659
+ const CADRE_INVITE_USAGE_SELECT_SQL = 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage';
511
660
  /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
512
661
  function cadreInviteUsageRowOf(row) {
513
662
  return {
@@ -516,11 +665,37 @@ function cadreInviteUsageRowOf(row) {
516
665
  peerId: row.PeerId,
517
666
  peerKey: row.PeerKey,
518
667
  peerStampId: row.PeerStampId,
519
- ownerStampId: row.OwnerStampId ?? null,
520
668
  inviteSig: row.InviteSig,
521
669
  peerSig: row.PeerSig,
522
670
  };
523
671
  }
672
+ /** 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. */
673
+ const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
674
+ /** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
675
+ function ownerGrantRowOf(row) {
676
+ return {
677
+ key: row.Key,
678
+ label: row.Label ?? null,
679
+ expiresAt: row.ExpiresAt == null ? null : String(row.ExpiresAt),
680
+ totalUses: row.TotalUses ?? null,
681
+ issuerKey: row.IssuerKey,
682
+ issuerSig: row.IssuerSig,
683
+ stampId: row.StampId,
684
+ };
685
+ }
686
+ /** The full scan the `OwnerGrantUsage` membership view loads, in the order {@link ownerGrantUsageRowOf} reads it. */
687
+ const OWNER_GRANT_USAGE_SELECT_SQL = 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage';
688
+ /** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
689
+ function ownerGrantUsageRowOf(row) {
690
+ return {
691
+ usageStampId: row.UsageStampId,
692
+ grantKey: row.GrantKey,
693
+ seatedKey: row.SeatedKey,
694
+ seatedStampId: row.SeatedStampId,
695
+ grantSig: row.GrantSig,
696
+ ownerSig: row.OwnerSig,
697
+ };
698
+ }
524
699
  /** A `JoinedStrand` row as a {@link StrandRow}; the schema's `KnownType` check backs the `Type` cast. */
525
700
  function joinedStrandRow(row) {
526
701
  return {
@@ -528,8 +703,20 @@ function joinedStrandRow(row) {
528
703
  MemberPrivateKey: row.MemberPrivateKey,
529
704
  Type: row.Type,
530
705
  FounderOwnerKey: null,
706
+ FounderPeerId: null,
531
707
  };
532
708
  }
709
+ /** A party-own `Strand` row as a {@link StrandRow}; the `Type` cast as {@link joinedStrandRow}'s. */
710
+ function strandRow(row) {
711
+ return {
712
+ Id: row.Id,
713
+ MemberPrivateKey: row.MemberPrivateKey,
714
+ Type: row.Type,
715
+ FounderOwnerKey: row.FounderOwnerKey,
716
+ FounderPeerId: row.FounderPeerId,
717
+ };
718
+ }
719
+ const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
533
720
  const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
534
721
  function formationInviteRowOf(row) {
535
722
  return {
@@ -599,7 +786,7 @@ function joinRequestAddFields(row) {
599
786
  * `JoinSuccess` / `JoinFailure` row and the signature its `AuthorizedInsert` verifies, over the
600
787
  * row's columns in the schema's order (a null column signs as `''`, an integer as its text).
601
788
  */
602
- function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
789
+ async function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signMessage) {
603
790
  const at = String(outcome.RecordedAt);
604
791
  const { table, sql, fields, values } = outcome.kind === 'joined'
605
792
  ? {
@@ -612,9 +799,28 @@ function signJoinOutcomeInsert(partyId, requestStampId, outcome, ownerKey, signM
612
799
  fields: [requestStampId, at, outcome.Code, outcome.Reason],
613
800
  values: [requestStampId, outcome.RecordedAt, outcome.Code, outcome.Reason],
614
801
  };
615
- const signature = signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
802
+ const signature = await signMessage(buildAuthorizationMessage(`CadreControl.${table}`, 'add', partyId, fields));
616
803
  return { sql, params: [ownerKey, signature, ...values] };
617
804
  }
805
+ /** The text of a {@link ControlDatabaseConfig.schemaPath} file; Node.js only. */
806
+ async function readSchemaFile(schemaPath) {
807
+ if (typeof process === 'undefined' || !process.versions?.node) {
808
+ throw new Error('Loading schema from file is not supported in React Native. ' +
809
+ 'Remove the schemaPath option to use the embedded schema instead.');
810
+ }
811
+ try {
812
+ // Use require to conditionally load fs only in Node.js.
813
+ // This won't be bundled by React Native's Metro bundler — a dynamic
814
+ // import() would be statically picked up by Metro, so require is
815
+ // intentional here (cross-platform constraint, not lazy CommonJS).
816
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
817
+ const fs = require('fs/promises');
818
+ return await fs.readFile(schemaPath, 'utf-8');
819
+ }
820
+ catch (error) {
821
+ throw new Error(`Failed to load schema from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
822
+ }
823
+ }
618
824
  /**
619
825
  * ControlDatabase manages the CadreControl schema using Quereus with Optimystic backend.
620
826
  * It provides typed query methods for accessing control network data.
@@ -623,12 +829,33 @@ export class ControlDatabase {
623
829
  constructor(config) {
624
830
  this.db = null;
625
831
  this.collectionFactory = null;
832
+ this.txnBridge = null;
626
833
  this.initialized = false;
627
834
  this.membershipListener = null;
628
835
  this.guardedDeleteListener = null;
629
836
  this.controlWriteAbandonedListener = null;
630
837
  /** Tail of the local-write chain — see {@link withWriteLock}. */
631
838
  this.writeQueue = Promise.resolve();
839
+ /** Subscriptions handed out by {@link watchTables}. */
840
+ this.tableWatches = new TableWatchRegistry();
841
+ /** The cached membership tables; see {@link readMembershipRows}. */
842
+ this.revocationView = this.membershipView(REVOCATION_SELECT_SQL, 'revocations');
843
+ this.cadrePeerView = this.membershipView(CADRE_PEER_SELECT_SQL, 'cadre-peers');
844
+ this.ownerKeyView = this.membershipView(OWNER_KEY_SELECT_SQL, 'owner-keys');
845
+ this.cadreInviteView = this.membershipView(CADRE_INVITE_SELECT_SQL, 'cadre-invites');
846
+ this.cadreInviteUsageView = this.membershipView(CADRE_INVITE_USAGE_SELECT_SQL, 'cadre-invite-usages');
847
+ this.ownerGrantView = this.membershipView(OWNER_GRANT_SELECT_SQL, 'owner-grants');
848
+ this.ownerGrantUsageView = this.membershipView(OWNER_GRANT_USAGE_SELECT_SQL, 'owner-grant-usages');
849
+ /** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
850
+ this.membershipViews = new Map([
851
+ ['CadreControl.Revocation', this.revocationView],
852
+ ['CadreControl.CadrePeer', this.cadrePeerView],
853
+ ['CadreControl.OwnerKey', this.ownerKeyView],
854
+ ['CadreControl.CadreInvite', this.cadreInviteView],
855
+ ['CadreControl.CadreInviteUsage', this.cadreInviteUsageView],
856
+ ['CadreControl.OwnerGrant', this.ownerGrantView],
857
+ ['CadreControl.OwnerGrantUsage', this.ownerGrantUsageView],
858
+ ]);
632
859
  /**
633
860
  * Locked write bodies currently running (see {@link runWriteBody}); what
634
861
  * {@link readRowsOnce} consults to route an unlocked read around a write that has
@@ -664,6 +891,7 @@ export class ControlDatabase {
664
891
  return;
665
892
  }
666
893
  log('Initializing ControlDatabase for party: %s', this.config.partyId);
894
+ this.invalidateMembershipViews();
667
895
  // Create database instance
668
896
  this.db = new Database();
669
897
  // Register crypto plugin (provides digest, sign, verify functions)
@@ -691,6 +919,7 @@ export class ControlDatabase {
691
919
  // Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
692
920
  registerPartyIdFunction(this.db, this.config.partyId);
693
921
  this.collectionFactory = pluginResult.collectionFactory;
922
+ this.txnBridge = pluginResult.txnBridge;
694
923
  // Inject the libp2p node into the collection factory
695
924
  t0 = performance.now();
696
925
  this.collectionFactory.registerLibp2pNode(networkName, this.config.libp2pNode, this.config.coordinatedRepo);
@@ -737,48 +966,77 @@ export class ControlDatabase {
737
966
  t0 = performance.now();
738
967
  await this.loadSchema();
739
968
  timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
969
+ this.watchMembershipViews();
740
970
  this.initialized = true;
741
971
  log('ControlDatabase initialized successfully');
742
972
  }
973
+ /**
974
+ * Drop a membership view whenever its table changes: a commit made here, or a change this
975
+ * node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
976
+ * does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
977
+ * subscription cannot hear. {@link close} disposes it with the rest.
978
+ */
979
+ watchMembershipViews() {
980
+ this.tableWatches.watch(this.db, [...this.membershipViews.keys()], () => Promise.resolve(), {
981
+ label: 'membership-view',
982
+ onInvalidate: (table) => this.membershipViews.get(table)?.invalidate(),
983
+ });
984
+ }
985
+ /**
986
+ * {@link watchTables} on the membership tables: the seven the membership views copy
987
+ * (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
988
+ * `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
989
+ * read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
990
+ * reads can depend on and no list of them lives outside this class.
991
+ */
992
+ watchMembershipTables(onChange, options) {
993
+ return this.watchTables([...this.membershipViews.keys()], onChange, options);
994
+ }
995
+ /**
996
+ * Empty every membership view, so the next read of each loads afresh and no load in
997
+ * flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
998
+ * a sibling becomes reachable (its identify on a new connection): a machine that was offline
999
+ * while that sibling committed learns of the commit only when it next reads the block from
1000
+ * storage, and dropping the views makes the pass that starts then read now rather than after
1001
+ * `MEMBERSHIP_VIEW_MAX_AGE_MS`.
1002
+ */
1003
+ invalidateMembershipViews() {
1004
+ for (const view of this.membershipViews.values()) {
1005
+ view.invalidate();
1006
+ }
1007
+ }
743
1008
  async loadSchema() {
744
- let schemaContent;
1009
+ let declaration;
745
1010
  if (this.config.schemaPath) {
746
- // Load from file if explicitly provided (for testing or custom schemas)
747
- // This only works in Node.js environments
748
1011
  log('Loading schema from file: %s', this.config.schemaPath);
749
- // Check if we're in a Node.js environment
750
- if (typeof process !== 'undefined' && process.versions?.node) {
751
- try {
752
- // Use require to conditionally load fs only in Node.js.
753
- // This won't be bundled by React Native's Metro bundler — a dynamic
754
- // import() would be statically picked up by Metro, so require is
755
- // intentional here (cross-platform constraint, not lazy CommonJS).
756
- // eslint-disable-next-line @typescript-eslint/no-require-imports
757
- const fs = require('fs/promises');
758
- schemaContent = await fs.readFile(this.config.schemaPath, 'utf-8');
759
- }
760
- catch (error) {
761
- throw new Error(`Failed to load schema from ${this.config.schemaPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
762
- }
763
- }
764
- else {
765
- throw new Error('Loading schema from file is not supported in React Native. ' +
766
- 'Remove the schemaPath option to use the embedded schema instead.');
767
- }
1012
+ declaration = controlSchemaFileDeclaration(await readSchemaFile(this.config.schemaPath));
768
1013
  }
769
1014
  else {
770
- // Use embedded schema for cross-platform compatibility
771
1015
  log('Using embedded control schema');
772
- schemaContent = CONTROL_SCHEMA;
1016
+ declaration = CONTROL_SCHEMA_DECLARATION;
773
1017
  }
1018
+ // Declaring runs no DDL; it only records the declaration the check and the apply both read.
1019
+ await this.db.exec(declaration);
1020
+ // The apply below is also the MIGRATION of a warm store written under an older control
1021
+ // schema version: its diff runs each version's changes in order, every one applied to a
1022
+ // table's catalog record without a row rewritten, and ends by recording this build's version
1023
+ // on the store. A store the apply must not touch is refused HERE, before it runs anything: one
1024
+ // a newer release migrated (this machine is the one to update), one from before migrations,
1025
+ // or one whose diff would rewrite or discard rows (`control-schema-version.ts`).
1026
+ await refuseIncompatibleControlStore(this.db, this.config.partyId);
1027
+ await this.applyControlSchema();
1028
+ log('Schema loaded and executed');
1029
+ }
1030
+ /** Apply the declared control schema, retrying transient cohort failures. */
1031
+ async applyControlSchema() {
774
1032
  // Schema init is a DISTRIBUTED write, so it gets the same bounded transient-failure
775
1033
  // retry every other control write does ({@link lockedWithRetry}). Every CadreControl
776
- // table is optimystic-backed (the default vtab was routed to optimystic just above),
1034
+ // table is optimystic-backed (`initialize` routes the default vtab to optimystic),
777
1035
  // so each `create table` needs a super-majority of the party's peers to answer. On a
778
1036
  // node joining a party that already has two live members a single unanswered peer
779
1037
  // during any one create otherwise kills startup outright.
780
1038
  //
781
- // Re-running the WHOLE `exec` after a partial failure is safe, and this is the
1039
+ // Re-running the apply after a partial failure is safe, and this is the
782
1040
  // non-obvious part:
783
1041
  //
784
1042
  // - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
@@ -786,11 +1044,6 @@ export class ControlDatabase {
786
1044
  // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
787
1045
  // A table the live catalog already lists generates no statements at all, which is what
788
1046
  // makes an apply over an already-complete schema (a warm start) a no-op.
789
- // NOTE: a table the catalog lists with DIFFERENT constraint text diffs to
790
- // `alter table ... drop constraint`, which the optimystic module refuses, so a store
791
- // written by a build with older constraint text fails here on every start (policy:
792
- // recreate, docs/architecture.md after the Control Network table). If backwards
793
- // compatibility becomes a requirement, this apply is the migration site.
794
1047
  // - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
795
1048
  // undo journal; when a step fails it runs that journal in reverse, re-renders the
796
1049
  // catalog, compares it against a fingerprint taken BEFORE the apply, and only then
@@ -812,6 +1065,20 @@ export class ControlDatabase {
812
1065
  // argument is a statement about the pair, not about Quereus alone — pinned by
813
1066
  // `test/control-schema-apply-unwind.spec.ts`.
814
1067
  //
1068
+ // The same argument covers the statements a MIGRATION runs (`control-schema-version.ts`'s
1069
+ // `catalog-only` kinds, from Optimystic 1.16.0): the plugin's `alterTable` arms (add column,
1070
+ // drop column, add and drop CHECK) and the tag change write the table's catalog record under
1071
+ // the batch's per-statement checkpoint, so a step that throws withdraws its own write, and a
1072
+ // batch whose commit fails is discarded whole and the tables it wrote re-persist on their next
1073
+ // touch. A change the record already holds — another machine migrated first — is adopted with
1074
+ // nothing written, and an undo of an adopted change touches this machine's catalog only. The
1075
+ // one arm Quereus marks irreversible is `drop column`: once it has run, a later failure
1076
+ // unwinds nothing (the apply reports the schema as partially migrated), the plugin's
1077
+ // `endSchemaBatch` commits the steps that landed — or, when that commit fails, marks their
1078
+ // tables to re-persist on next touch — and the retry's diff runs only the steps still
1079
+ // missing. No row is lost either way: rows keep a dropped column's field until they are
1080
+ // rewritten, and nothing reads it.
1081
+ //
815
1082
  // Only the "cohort did not answer, nothing committed" class is retried; the classifier
816
1083
  // vetoes indeterminate commits and does not match `Missing block`, which is a durable
817
1084
  // convergence fault a retry cannot heal (tracked separately) and must keep propagating.
@@ -823,8 +1090,7 @@ export class ControlDatabase {
823
1090
  // also what lets schema init absorb optimystic's self-coordination grace refusal, which
824
1091
  // is unsafe for control writes in general. Why, on
825
1092
  // `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
826
- await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
827
- log('Schema loaded and executed');
1093
+ await this.lockedWithRetry(() => this.db.exec(APPLY_CONTROL_SCHEMA), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
828
1094
  }
829
1095
  /**
830
1096
  * One UNRETRIED drain of a control read to rows — every read in this class bottoms out
@@ -904,8 +1170,9 @@ export class ControlDatabase {
904
1170
  * statement to its transaction opening.
905
1171
  */
906
1172
  async readRowsOnce(sql, params, underWriteLock) {
1173
+ const committed = this.writeInFlight(underWriteLock);
907
1174
  let iterator;
908
- if (this.writeInFlight(underWriteLock)) {
1175
+ if (committed) {
909
1176
  log('read-eval: a write is in flight (%s), asking for a committed read: %s', this.db.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
910
1177
  iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
911
1178
  }
@@ -916,7 +1183,7 @@ export class ControlDatabase {
916
1183
  for await (const row of iterator) {
917
1184
  rows.push(row);
918
1185
  }
919
- return rows;
1186
+ return { rows, committed };
920
1187
  }
921
1188
  /**
922
1189
  * Whether a control read should ask for a committed read — see {@link readRowsOnce}.
@@ -974,7 +1241,15 @@ export class ControlDatabase {
974
1241
  * held, and its membership read is opted out at {@link queryCadrePeers}. If another
975
1242
  * under-lock callback seam is ever added, audit its read graph the same way.
976
1243
  */
977
- readRows(sql, params, label, retry = true) {
1244
+ async readRows(sql, params, label, retry = true) {
1245
+ return (await this.readRowsRouted(sql, params, label, retry)).rows;
1246
+ }
1247
+ /**
1248
+ * {@link readRows}, also saying whether the attempt that answered took the committed-read
1249
+ * path — which a {@link MembershipView} load needs, since it may not keep such rows (see
1250
+ * {@link membershipView}).
1251
+ */
1252
+ readRowsRouted(sql, params, label, retry) {
978
1253
  // `retry: false` marks the reads that may run inside a locked write body, which is
979
1254
  // also what readRowsOnce's committed-read routing must know (see writeInFlight).
980
1255
  // NOTE: the membership-gate refresh passes it on EVERY trigger, including the
@@ -989,21 +1264,51 @@ export class ControlDatabase {
989
1264
  // it changes log attribution only, never behaviour (same layering as lockedWithRetry).
990
1265
  return retryControlRead(() => this.readRowsOnce(sql, params, false), { ...this.controlReadRetryPacing, label });
991
1266
  }
1267
+ /**
1268
+ * A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
1269
+ * funnel. Rows a committed read served are never kept: that read may initialize an untouched
1270
+ * table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
1271
+ * on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
1272
+ * event would ever drop them.
1273
+ */
1274
+ membershipView(sql, label) {
1275
+ return new MembershipView(async (retry) => {
1276
+ const { rows, committed } = await this.readRowsRouted(sql, undefined, label, retry);
1277
+ return { rows, installable: !committed };
1278
+ });
1279
+ }
1280
+ /**
1281
+ * A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
1282
+ * and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
1283
+ * `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
1284
+ * membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
1285
+ * Callers build fresh result objects from them and never mutate them.
1286
+ *
1287
+ * Reads the table directly instead when a caller under the write lock (`retry: false`) has an
1288
+ * explicit transaction open: such a read sees the transaction's own uncommitted rows, which
1289
+ * the view does not hold, and a rollback fires no change event, so keeping them would cache
1290
+ * rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
1291
+ * this keeps one added later, or a spec's raw-handle transaction, correct.
1292
+ *
1293
+ * Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
1294
+ * schema's CHECK constraints, which read inside the committing transaction, the redemption's
1295
+ * one read of the held credential row ({@link queryCadreInvite},
1296
+ * {@link queryOwnerGrant}), and the indexed usage counts.
1297
+ */
1298
+ readMembershipRows(view, retry) {
1299
+ this.ensureInitialized();
1300
+ if (!retry && !this.db.getAutocommit()) {
1301
+ return view.readThrough(retry);
1302
+ }
1303
+ return view.read(retry);
1304
+ }
992
1305
  /**
993
1306
  * Query all strands from the control database
994
1307
  */
995
1308
  async queryStrands() {
996
1309
  this.ensureInitialized();
997
- const results = [];
998
- for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
999
- results.push({
1000
- Id: row.Id,
1001
- MemberPrivateKey: row.MemberPrivateKey,
1002
- Type: row.Type,
1003
- FounderOwnerKey: row.FounderOwnerKey,
1004
- });
1005
- }
1006
- return results;
1310
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand`, undefined, 'strands');
1311
+ return rows.map(strandRow);
1007
1312
  }
1008
1313
  /**
1009
1314
  * Read a single strand row by id, or null when absent. Single-row sibling of
@@ -1013,20 +1318,23 @@ export class ControlDatabase {
1013
1318
  */
1014
1319
  async queryStrand(strandId) {
1015
1320
  this.ensureInitialized();
1016
- for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?', [strandId], 'strand')) {
1017
- return {
1018
- Id: row.Id,
1019
- MemberPrivateKey: row.MemberPrivateKey,
1020
- Type: row.Type,
1021
- FounderOwnerKey: row.FounderOwnerKey,
1022
- };
1023
- }
1024
- return null;
1321
+ const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
1322
+ return rows.length === 0 ? null : strandRow(rows[0]);
1323
+ }
1324
+ /**
1325
+ * {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
1326
+ * resolves a bound invite's host strand with it and later records consent against that
1327
+ * stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
1328
+ */
1329
+ async queryStrandWithStamp(strandId) {
1330
+ this.ensureInitialized();
1331
+ const rows = await this.readRows(`select ${STRAND_COLUMNS}, StampId from CadreControl.Strand where Id = ?`, [strandId], 'strand-with-stamp');
1332
+ return rows.length === 0 ? null : { ...strandRow(rows[0]), StampId: rows[0].StampId };
1025
1333
  }
1026
1334
  /**
1027
1335
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
1028
1336
  * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
1029
- * never founds, so `FounderOwnerKey` is always null. Read raw, like {@link queryStrands}:
1337
+ * never founds, so `FounderOwnerKey` and `FounderPeerId` are always null. Read raw, like {@link queryStrands}:
1030
1338
  * no retired-stamp filter.
1031
1339
  */
1032
1340
  async queryJoinedStrands() {
@@ -1101,29 +1409,72 @@ export class ControlDatabase {
1101
1409
  *
1102
1410
  * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1103
1411
  * insert is refused by the schema whenever any physical row exists, so this must see one.
1412
+ * Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
1413
+ * physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
1414
+ * one storage read.
1104
1415
  */
1105
1416
  async hasOwnerKey() {
1106
1417
  this.ensureInitialized();
1107
- for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
1108
- return row.Count > 0;
1109
- }
1110
- return false;
1418
+ return (await this.readMembershipRows(this.ownerKeyView, true)).length > 0;
1111
1419
  }
1112
1420
  /**
1113
1421
  * Idempotent genesis: insert `key` as the founding owner key only when
1114
1422
  * the party has none yet. Returns true if it inserted, false if an owner
1115
1423
  * key already existed (so a repeat `--owner` start is a no-op).
1424
+ *
1425
+ * Before the insert, gives every control table its empty collection
1426
+ * ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
1427
+ * forks if another machine creates the same collection independently; before genesis the
1428
+ * party has no owner, so no other machine can have been admitted and none can hold any of
1429
+ * its collections. The order makes the step repeatable: a crash between it and the insert
1430
+ * leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
1431
+ * collections still missing, while a founder restart finds the row and commits nothing, so
1432
+ * it can never write an empty second history beside a table another machine has since
1433
+ * written. A failure propagates with nothing inserted.
1434
+ *
1435
+ * @param label - the user's name for the key ({@link insertOwnerKey}).
1116
1436
  */
1117
- async ensureOwnerKey(key) {
1437
+ async ensureOwnerKey(key, label) {
1118
1438
  this.ensureInitialized();
1119
1439
  const trimmed = requireEd25519PublicKeyB64(key, 'owner key');
1120
1440
  if (await this.hasOwnerKey()) {
1121
1441
  log('Owner key already present; skipping genesis insert');
1122
1442
  return false;
1123
1443
  }
1124
- await this.insertOwnerKey(trimmed);
1444
+ // Two locked steps one after the other: the lock is not re-entrant, and the insert takes it itself.
1445
+ await this.commitEmptyControlCollections();
1446
+ await this.insertOwnerKey(trimmed, label);
1125
1447
  return true;
1126
1448
  }
1449
+ /**
1450
+ * Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
1451
+ * tree that has none, so a read of a table nobody has written is answered from this machine's
1452
+ * storage instead of asking the block's cohort to prove the collection absent — a round trip
1453
+ * per read on a party of several machines, and an error or a wait while they are offline.
1454
+ * Joining machines get these collections the ordinary way, by reading them from the cohort.
1455
+ *
1456
+ * Under the write lock: the helper commits whatever is staged in those collections outside any
1457
+ * SQL transaction, which is sound only while nothing else writes.
1458
+ *
1459
+ * Parties founded before this step, and tables a later control schema version adds, have no
1460
+ * such collections, and nothing creates them later: the start-time schema apply runs on every
1461
+ * machine, possibly alone, where committing one would be exactly the fork genesis avoids.
1462
+ *
1463
+ * NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
1464
+ * 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
1465
+ * transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
1466
+ * 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
1467
+ * node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
1468
+ * geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
1469
+ * (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
1470
+ * `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
1471
+ * founding would use too; do not build a private batching path.
1472
+ */
1473
+ async commitEmptyControlCollections() {
1474
+ const t0 = performance.now();
1475
+ const committed = await this.withWriteLock(() => createEmptyCollections(this.db, this.txnBridge, [CONTROL_SCHEMA_NAME]));
1476
+ log('Committed %d empty control collections in %dms', committed, Math.round(performance.now() - t0));
1477
+ }
1127
1478
  /**
1128
1479
  * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
1129
1480
  *
@@ -1150,8 +1501,9 @@ export class ControlDatabase {
1150
1501
  return new Set((await this.queryOwnerKeyRows(retry)).map(row => row.key));
1151
1502
  }
1152
1503
  /**
1153
- * Every live `OwnerKey` row with its persisted proof ({@link OwnerKeyRow}) — what the
1154
- * trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`). Rows whose
1504
+ * Every live `OwnerKey` row with its label and persisted proof ({@link OwnerKeyRow}) — what
1505
+ * the trusted-owner anchor sync derives from (`CadreNode.syncOwnerAnchor`) and the owner-key
1506
+ * listing shows (`CadreNode.listOwnerKeys`). Rows whose
1155
1507
  * `StampId` is retired in `CadreControl.Revocation` are excluded here, as
1156
1508
  * {@link queryCadrePeers} excludes them and for the reason on {@link getOwnerKeys}, which
1157
1509
  * reads through this. A row with no stamp (the schema permits it; no writer produces one)
@@ -1163,16 +1515,14 @@ export class ControlDatabase {
1163
1515
  *
1164
1516
  * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1165
1517
  * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1166
- * signers); omitted, they are read here. Before the ledger marker is filed every
1167
- * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1168
- * membership refresh at one such read for the anchor.
1518
+ * signers); omitted, they are read here. Both come from the same `Revocation` view
1519
+ * ({@link readMembershipRows}), so passing the set saves only the filtering.
1169
1520
  */
1170
1521
  async queryOwnerKeyRows(retry = true, retired) {
1171
1522
  this.ensureInitialized();
1172
1523
  const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1173
1524
  const rows = [];
1174
- const sql = 'select Key, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1175
- for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1525
+ for (const row of await this.readMembershipRows(this.ownerKeyView, retry)) {
1176
1526
  const stampId = row.StampId ?? null;
1177
1527
  if (stampId === null) {
1178
1528
  log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
@@ -1183,6 +1533,7 @@ export class ControlDatabase {
1183
1533
  }
1184
1534
  rows.push({
1185
1535
  key: row.Key,
1536
+ label: row.Label ?? null,
1186
1537
  stampId,
1187
1538
  vouchOwner: row.VouchOwner ?? null,
1188
1539
  vouchSig: row.VouchSig ?? null,
@@ -1203,12 +1554,8 @@ export class ControlDatabase {
1203
1554
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
1204
1555
  * that seam to model the replicated live-row-plus-tombstone merge state.
1205
1556
  *
1206
- * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
1207
- * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
1208
- * while `Revocation` has never been written every call consults the cohort about it, and
1209
- * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
1210
- * the exclusion into one statement would not help (it reads the same block), and caching
1211
- * the set would hide a tombstone arriving by replication.
1557
+ * Both tables come from their membership views ({@link readMembershipRows}), so a call after
1558
+ * the first costs no storage read until one of the two tables changes.
1212
1559
  *
1213
1560
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
1214
1561
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -1231,42 +1578,27 @@ export class ControlDatabase {
1231
1578
  this.ensureInitialized();
1232
1579
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1233
1580
  const rows = [];
1234
- // The proof columns lead and the record columns follow, so this scan keeps a prefix no
1235
- // other `CadrePeer` statement shares (`queryPeerRecord` starts `PeerId, PublicKey`): the
1236
- // read-retry spec rigs the membership scan by that prefix.
1237
- const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
1238
- for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
1239
- const stampId = row.StampId ?? null;
1240
- if (stampId !== null && revoked.has(stampId)) {
1581
+ for (const row of await this.readMembershipRows(this.cadrePeerView, retry)) {
1582
+ const peer = cadrePeerRowOf(row);
1583
+ if (peer.stampId !== null && revoked.has(peer.stampId)) {
1241
1584
  continue;
1242
1585
  }
1243
- rows.push({
1244
- peerId: row.PeerId,
1245
- publicKey: row.PublicKey ?? null,
1246
- multiaddr: row.Multiaddr ?? null,
1247
- updatedAt: row.UpdatedAt ?? 0,
1248
- profile: row.Profile ?? null,
1249
- sig: row.Sig ?? null,
1250
- stampId,
1251
- vouchOwner: row.VouchOwner ?? null,
1252
- vouchSig: row.VouchSig ?? null,
1253
- vouchUsage: row.VouchUsage ?? null,
1254
- });
1586
+ rows.push(peer);
1255
1587
  }
1256
1588
  return rows;
1257
1589
  }
1258
1590
  /**
1259
1591
  * The cadre's peers as a seed or a redemption reply carries them: every live `CadrePeer`
1260
- * row with its addresses, each flagged `isOwner` when the key behind its peer id is in
1261
- * the replicated `OwnerKey` table ({@link projectSeedPeers}).
1592
+ * row with its addresses, the minter and every always-on machine marked preferred
1593
+ * ({@link projectSeedPeers}).
1262
1594
  *
1263
1595
  * Read through {@link queryCadrePeers}, never a raw `CadrePeer` select, so a removed
1264
1596
  * member's addresses are never packed into a seed or handed to a redeeming device as a
1265
1597
  * dial hint: a revoked peer is off the addressable surface everywhere, and this is one of
1266
1598
  * its exits.
1267
1599
  */
1268
- async querySeedPeers() {
1269
- return projectSeedPeers(await this.queryCadrePeers(), await this.getOwnerKeys());
1600
+ async querySeedPeers(minterPeerId) {
1601
+ return projectSeedPeers(await this.queryCadrePeers(), minterPeerId);
1270
1602
  }
1271
1603
  /**
1272
1604
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
@@ -1305,10 +1637,11 @@ export class ControlDatabase {
1305
1637
  * guard. A read retrying there would sleep its backoff HOLDING the write lock
1306
1638
  * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
1307
1639
  * the write funnel already re-runs the body's reads when it re-runs the body. The
1308
- * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
1309
- * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
1310
- * default retried path. Explicit per call rather than an ambient flag: unlocked reads
1311
- * run concurrently with a locked body, so `this`-state cannot tell the two apart.
1640
+ * unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
1641
+ * read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
1642
+ * rather than an ambient flag: unlocked reads run concurrently with a locked body, so
1643
+ * `this`-state cannot tell the two apart. The same flag threads through
1644
+ * {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
1312
1645
  */
1313
1646
  async queryStampId(table, keyValue, retry = true) {
1314
1647
  this.ensureInitialized();
@@ -1321,8 +1654,8 @@ export class ControlDatabase {
1321
1654
  /**
1322
1655
  * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
1323
1656
  * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
1324
- * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
1325
- * straight to {@link queryStampId}.
1657
+ * `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
1658
+ * the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
1326
1659
  */
1327
1660
  queryCadrePeerStampId(peerId) {
1328
1661
  return this.queryStampId('CadrePeer', peerId);
@@ -1377,18 +1710,17 @@ export class ControlDatabase {
1377
1710
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
1378
1711
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
1379
1712
  *
1380
- * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
1381
- * `Revocation` has never been written, this node does not hold its block, and Optimystic
1382
- * consults a block's cohort on every read of a block it does not hold: 1 consult per
1383
- * call (measured), each a round trip to every other member on a multi-machine party. The
1384
- * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
1385
- * at most once per read-repair window (10 s). Do not cache the set instead: a cache
1386
- * cannot see a tombstone arriving by replication, so it would delay a revocation. The
1387
- * row count only grows (append-only) but stays small while removals are rare; revisit if
1713
+ * Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
1714
+ * every change to the table, local or replicated into this node's storage, so a tombstone
1715
+ * arriving by replication is seen on the next call. The cost that matters is the storage
1716
+ * layer's, paid by a cold view's load: while `Revocation` has never been written, this node
1717
+ * does not hold its block, and Optimystic consults a block's cohort on every read of a block
1718
+ * it does not hold, each a round trip to every other member on a multi-machine party. The row
1719
+ * count only grows (append-only) but stays small while removals are rare; revisit if
1388
1720
  * removals become routine and the scan itself shows up in a profile.
1389
1721
  *
1390
1722
  * An isolated node that has never received the `Revocation` block reads it as holding no
1391
- * revocations instead of throwing — see {@link readRevokedStampRows}.
1723
+ * revocations instead of throwing — see {@link readRevocationRowsOrNone}.
1392
1724
  *
1393
1725
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
1394
1726
  * under the write lock — see that method's note.
@@ -1396,21 +1728,25 @@ export class ControlDatabase {
1396
1728
  async queryRevokedStamps(tableName, retry = true) {
1397
1729
  this.ensureInitialized();
1398
1730
  const stamps = new Set();
1399
- for (const row of await this.readRevokedStampRows(tableName, retry)) {
1400
- stamps.add(row.StampId);
1731
+ for (const row of await this.readRevocationRowsOrNone(retry)) {
1732
+ if (row.TableName === tableName) {
1733
+ stamps.add(row.StampId);
1734
+ }
1401
1735
  }
1402
1736
  return stamps;
1403
1737
  }
1404
1738
  /**
1405
- * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
1406
- * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1739
+ * The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
1740
+ * load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1407
1741
  * the block and could ask no other cohort member about it. Every other failure rethrows.
1408
1742
  * The retry in {@link readRows} runs first, so a node whose connectivity returns within
1409
- * the read budget still gets the real answer.
1743
+ * the read budget still gets the real answer. The empty answer is never installed in the
1744
+ * view (a failed load keeps nothing), so the next call asks again.
1410
1745
  *
1411
1746
  * Without this, a machine cut off from every other machine in its party before it ever
1412
- * received the block — the block does not exist anywhere until the owner files the
1413
- * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
1747
+ * received the block — a joining machine gets it only by reading it from the cohort, and
1748
+ * on a party founded before genesis created every control collection it exists nowhere
1749
+ * until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
1414
1750
  * answer ANY membership, peer-record or device-token lookup, although it holds those
1415
1751
  * rows locally.
1416
1752
  *
@@ -1429,20 +1765,20 @@ export class ControlDatabase {
1429
1765
  *
1430
1766
  * NOTE: an isolated node pays the whole read retry (up to
1431
1767
  * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
1432
- * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
1433
- * node's lookups are seen exceeding the admission gate's 2 s deadline
1434
- * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
1435
- * fails `cohort-unreachable`.
1768
+ * answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
1769
+ * load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
1770
+ * admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
1771
+ * read when the first attempt fails `cohort-unreachable`.
1436
1772
  */
1437
- async readRevokedStampRows(tableName, retry) {
1773
+ async readRevocationRowsOrNone(retry) {
1438
1774
  try {
1439
- return await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry);
1775
+ return await this.readMembershipRows(this.revocationView, retry);
1440
1776
  }
1441
1777
  catch (error) {
1442
1778
  if (!isCohortUnreachableRead(error)) {
1443
1779
  throw error;
1444
1780
  }
1445
- log('revoked-stamps(%s): no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', tableName, error);
1781
+ log('revoked-stamps: no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', error);
1446
1782
  return [];
1447
1783
  }
1448
1784
  }
@@ -1454,10 +1790,10 @@ export class ControlDatabase {
1454
1790
  * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1455
1791
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1456
1792
  *
1457
- * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1458
- * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1459
- * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1460
- * admits no other row under `'Revocation'`.
1793
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
1794
+ * retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
1795
+ * `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
1796
+ * TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
1461
1797
  *
1462
1798
  * `retry: false` from a caller that reads under the write lock, for the reason on
1463
1799
  * {@link queryCadrePeers}.
@@ -1465,24 +1801,16 @@ export class ControlDatabase {
1465
1801
  async queryRevocations(retry = true) {
1466
1802
  this.ensureInitialized();
1467
1803
  const rows = [];
1468
- const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
1469
- for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
1470
- if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1471
- continue;
1804
+ for (const row of await this.readMembershipRows(this.revocationView, retry)) {
1805
+ if (row.TableName !== REVOCATION_LEDGER_MARKER.tableName) {
1806
+ rows.push(revocationRowOf(row));
1472
1807
  }
1473
- rows.push({
1474
- tableName: row.TableName,
1475
- rowKey: row.RowKey,
1476
- stampId: row.StampId,
1477
- reissuedAt: row.ReissuedAt ?? 0,
1478
- signerKey: row.SignerKey,
1479
- signerSig: row.SignerSig,
1480
- });
1481
1808
  }
1482
1809
  return rows;
1483
1810
  }
1484
1811
  /**
1485
- * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
1812
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
1813
+ * `CadrePeer` membership view ({@link readMembershipRows}).
1486
1814
  *
1487
1815
  * Returns null when no row exists, or when the row's `StampId` is retired in
1488
1816
  * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
@@ -1498,29 +1826,34 @@ export class ControlDatabase {
1498
1826
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
1499
1827
  * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
1500
1828
  * the publisher signed.
1829
+ *
1830
+ * `retry: false` from the one caller that reads under the write lock,
1831
+ * {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
1832
+ */
1833
+ async queryPeerRecord(peerId, retry = true) {
1834
+ const row = await this.findCadrePeerRow(peerId, retry);
1835
+ return row === null ? null : peerAddressRecordOf(row);
1836
+ }
1837
+ /**
1838
+ * One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
1839
+ * or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
1840
+ * and the two writers that decide over the row inside their locked body
1841
+ * ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
1842
+ * {@link queryCadrePeers}, and the same `retry` contract.
1501
1843
  */
1502
- async queryPeerRecord(peerId) {
1844
+ async findCadrePeerRow(peerId, retry) {
1503
1845
  this.ensureInitialized();
1504
- const revoked = await this.queryRevokedStamps('CadrePeer');
1505
- for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Profile, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
1506
- const stampId = row.StampId ?? null;
1507
- if (stampId !== null && revoked.has(stampId)) {
1508
- return null;
1509
- }
1510
- const multiaddr = row.Multiaddr ?? '';
1511
- return {
1512
- peerId: row.PeerId,
1513
- publicKey: row.PublicKey ?? '',
1514
- addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
1515
- updatedAt: row.UpdatedAt ?? 0,
1516
- profile: row.Profile ?? null,
1517
- sig: row.Sig ?? '',
1518
- };
1846
+ const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1847
+ const row = (await this.readMembershipRows(this.cadrePeerView, retry)).find((candidate) => candidate.PeerId === peerId);
1848
+ if (row === undefined) {
1849
+ return null;
1519
1850
  }
1520
- return null;
1851
+ const peer = cadrePeerRowOf(row);
1852
+ return peer.stampId !== null && revoked.has(peer.stampId) ? null : peer;
1521
1853
  }
1522
1854
  /**
1523
- * Apply a peer's own self-signed address-record update to an existing row.
1855
+ * Apply a peer's own self-signed address-record update to its existing row: read the row
1856
+ * under the write lock, have `sign` build the record over it, and write that record.
1524
1857
  *
1525
1858
  * Authorization is carried entirely by the record: the `Sig` column (verified
1526
1859
  * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
@@ -1529,6 +1862,21 @@ export class ControlDatabase {
1529
1862
  * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
1530
1863
  * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
1531
1864
  *
1865
+ * The read, the decision and the signature all run INSIDE the locked body, which is what
1866
+ * lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
1867
+ * re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
1868
+ * When this write loses that race the commit is refused as a lost update and re-run, and the
1869
+ * re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
1870
+ * signed before the race could no longer exceed — and signs past it. A record signed outside
1871
+ * the body would be re-presented unchanged and refused again, this time by the schema's
1872
+ * monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
1873
+ * (see {@link queryStampId}'s NOTE).
1874
+ *
1875
+ * Returns the record actually written, so the caller's bookkeeping describes the row rather
1876
+ * than the first record it signed; null, and nothing written, when no readable row exists:
1877
+ * the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
1878
+ * as absent every heartbeat, and must not refresh it).
1879
+ *
1532
1880
  * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
1533
1881
  * is not: it only ever touches THIS node's own row (sole caller
1534
1882
  * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
@@ -1541,17 +1889,43 @@ export class ControlDatabase {
1541
1889
  * row, and a self insert happens once at startup, so the wasted refresh is a single read
1542
1890
  * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
1543
1891
  * path is worth exempting.
1892
+ *
1893
+ * @param peerId - the row to refresh: this node's own.
1894
+ * @param sign - builds the signed record over the row as read; called once per attempt.
1544
1895
  */
1545
- async updateSelfPeerRecord(record) {
1896
+ async updateSelfPeerRecord(peerId, sign) {
1546
1897
  this.ensureInitialized();
1547
- const multiaddr = record.addrs.join(',');
1548
- await this.execWrite(`
1549
- update CadreControl.CadrePeer
1550
- with context OwnerKey = null, Signature = ?
1551
- set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
1552
- where PeerId = ?
1553
- `, [record.sig, multiaddr, record.updatedAt, record.profile, record.sig, record.peerId], 'self-record-update');
1554
- log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
1898
+ const written = await this.lockedWithRetry(async () => {
1899
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
1900
+ // NOTE: this read of the Revocation and CadrePeer views is charged to the retry budget,
1901
+ // so a cold view load that waits out a per-peer read deadline leaves the attempt no room
1902
+ // for its one retry (the 14.7 s abandonment the policy's doc cites). The caller's read
1903
+ // outside the funnel warms the views, so this is a cache hit except when a table change
1904
+ // between the two reads dropped them — the racing case itself. If that residual ever
1905
+ // matters, measure which phase of the losing attempt pays the deadline first; the
1906
+ // retired-stamp check cannot simply move outside, since `publishSelfRecord`'s insert
1907
+ // path reads a revoked node's row as absent and relies on this body to skip it.
1908
+ const current = await this.queryPeerRecord(peerId, false);
1909
+ if (current === null) {
1910
+ return null;
1911
+ }
1912
+ const record = sign(current);
1913
+ // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1914
+ await this.db.exec(`
1915
+ update CadreControl.CadrePeer
1916
+ with context OwnerKey = null, Signature = ?
1917
+ set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
1918
+ where PeerId = ?
1919
+ `, [record.sig, record.addrs.join(','), record.updatedAt, record.profile, record.sig, peerId]);
1920
+ return record;
1921
+ }, LOST_UPDATE_RETRY_POLICY, 'self-record-update');
1922
+ if (written === null) {
1923
+ log('Self peer record not updated: no readable CadrePeer row for %s (absent, or its stamp retired)', peerId);
1924
+ }
1925
+ else {
1926
+ log('Self peer record updated: %s (updatedAt=%d)', peerId, written.updatedAt);
1927
+ }
1928
+ return written;
1555
1929
  }
1556
1930
  /**
1557
1931
  * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
@@ -1603,9 +1977,13 @@ export class ControlDatabase {
1603
1977
  }
1604
1978
  /**
1605
1979
  * Insert the initial owner key (bootstrap - no existing owners required)
1980
+ *
1981
+ * @param label - the user's name for the key, at most {@link OWNER_KEY_MAX_LABEL}
1982
+ * characters once trimmed; blank is stored as none.
1606
1983
  */
1607
- async insertOwnerKey(key) {
1984
+ async insertOwnerKey(key, label) {
1608
1985
  this.ensureInitialized();
1986
+ const storedLabel = ownerKeyLabel(label);
1609
1987
  log('Inserting owner key: %s', key);
1610
1988
  // Bootstrap is authorized by the schema's genesis branch — `(select count(1) from
1611
1989
  // committed.OwnerKey) = 0`, i.e. the party had no owner before this transaction — so no
@@ -1617,60 +1995,81 @@ export class ControlDatabase {
1617
1995
  // optimystic `StampId()` SQL function.
1618
1996
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1619
1997
  await this.execWrite(`
1620
- insert into CadreControl.OwnerKey (Key, StampId)
1998
+ insert into CadreControl.OwnerKey (Key, Label, StampId)
1621
1999
  with context OwnerKey = null, Signature = null
1622
- values (?, ?)
1623
- `, [key, stampId], 'owner-key-insert');
2000
+ values (?, ?, ?)
2001
+ `, [key, storedLabel, stampId], 'owner-key-insert');
1624
2002
  log('Owner key inserted');
1625
2003
  }
1626
2004
  /**
1627
2005
  * Owner-signed INSERT of a further `OwnerKey` row, notifying the membership listener once
1628
2006
  * it has committed. The signed-add branch of `OwnerKey.Authorized`: the signer must be an
1629
2007
  * owner in the pre-transaction snapshot, its signature covers the `'add'` digest over
1630
- * (Key, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a reader
1631
- * can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
2008
+ * (Key, Label, StampId), and the stored `VouchOwner`/`VouchSig` are that same pair, so a
2009
+ * reader can later judge the add against its own anchor (`verifyOwnerKeyVoucher`).
1632
2010
  *
1633
2011
  * Through {@link mutateCadrePeer}, the membership hub. The liveness read, the stamp and the
1634
2012
  * signature all sit inside the locked body: a {@link lockedWithRetry} re-run starts over a
1635
2013
  * rolled-back attempt with a fresh stamp, as {@link redeemCadreInvite} does.
1636
2014
  *
1637
- * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
1638
- * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
1639
- * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
1640
- * would collide on the primary key. Re-add from the machine that removed the key, or any
1641
- * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
1642
- * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
1643
- *
2015
+ * @param label - the user's name for the key, as {@link insertOwnerKey}'s.
1644
2016
  * @returns `true` when this call seated the row, `false` when `key` is already a live owner
1645
- * here (nothing written).
1646
- * @throws when this node holds `key`'s row under a retired stamp (above), or when the
1647
- * schema refuses the signer.
2017
+ * here (nothing written, whatever its label).
2018
+ * @throws when this node holds `key`'s row under a retired stamp
2019
+ * ({@link liveOwnerKeyStamp}), or when the schema refuses the signer.
1648
2020
  */
1649
- async insertOwnerKeyVouched(key, ownerKey, signMessage) {
2021
+ async insertOwnerKeyVouched(key, ownerKey, signMessage, label) {
1650
2022
  this.ensureInitialized();
2023
+ const storedLabel = ownerKeyLabel(label);
1651
2024
  return await this.mutateCadrePeer('owner-key-add', async () => {
1652
- // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1653
- const heldStamp = await this.queryStampId('OwnerKey', key, false);
1654
- if (heldStamp !== null) {
1655
- if (!(await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
1656
- log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
1657
- return false;
1658
- }
1659
- throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
1660
- '(OwnerKey rows are not reaped); re-add it from the machine that removed it');
2025
+ if (await this.liveOwnerKeyStamp(key) !== null) {
2026
+ log('insertOwnerKeyVouched: %s is already an owner; nothing written', key);
2027
+ return false;
1661
2028
  }
1662
2029
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1663
- const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, stampId));
1664
- // Bare `exec`: already inside the write lock, which is NOT re-entrant.
1665
- await this.db.exec(`
1666
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1667
- with context OwnerKey = ?, Signature = ?
1668
- values (?, ?, ?, ?)
1669
- `, [ownerKey, signature, key, stampId, ownerKey, signature]);
2030
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, storedLabel, stampId));
2031
+ await this.execOwnerKeyVouchedInsert({ key, label: storedLabel, stampId }, ownerKey, signature);
1670
2032
  log('Owner key %s added (vouched by %s)', key, ownerKey);
1671
2033
  return true;
1672
2034
  });
1673
2035
  }
2036
+ /**
2037
+ * The stamp of `key`'s live `OwnerKey` row here, or null when this node holds none: the
2038
+ * insert-if-absent read of {@link insertOwnerKeyVouched} and {@link redeemOwnerGrant}. Inside
2039
+ * the locked body (both reads `retry: false`; see queryStampId's NOTE).
2040
+ *
2041
+ * NOTE: re-adding a removed owner from a machine that still holds its retired physical row
2042
+ * is refused here by name: `OwnerKey` is not reaped, so a machine that received the
2043
+ * tombstone without the delete (see {@link getOwnerKeys}) keeps the row, and an insert there
2044
+ * would collide on the primary key. Re-add from the machine that removed the key, or any
2045
+ * machine the delete reached; if re-adding removed owners becomes routine, the fix is an
2046
+ * `OwnerKey` reap branch, gated so it can never take the table below `MinOneOwner`.
2047
+ *
2048
+ * @throws when this node holds `key`'s row under a retired stamp (above).
2049
+ */
2050
+ async liveOwnerKeyStamp(key) {
2051
+ const heldStamp = await this.queryStampId('OwnerKey', key, false);
2052
+ if (heldStamp === null) {
2053
+ return null;
2054
+ }
2055
+ if ((await this.queryRevokedStamps('OwnerKey', false)).has(heldStamp)) {
2056
+ throw new Error(`Cannot re-add owner key ${key} here: this node still holds the row it was removed under ` +
2057
+ '(OwnerKey rows are not reaped); re-add it from the machine that removed it');
2058
+ }
2059
+ return heldStamp;
2060
+ }
2061
+ /**
2062
+ * The signed-add statement of {@link insertOwnerKeyVouched} and {@link revouchOwnerKey}, the
2063
+ * stored voucher being the signing pair. Bare `exec`: callers hold the write lock, which is
2064
+ * NOT re-entrant.
2065
+ */
2066
+ async execOwnerKeyVouchedInsert(row, ownerKey, signature) {
2067
+ await this.db.exec(`
2068
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig)
2069
+ with context OwnerKey = ?, Signature = ?
2070
+ values (?, ?, ?, ?, ?)
2071
+ `, [ownerKey, signature, row.key, row.label, row.stampId, ownerKey, signature]);
2072
+ }
1674
2073
  /**
1675
2074
  * Owner-signed removal of one `OwnerKey` row plus the `Revocation` tombstone retiring its
1676
2075
  * stamp, notifying the membership listener once it has committed. The remove branch of
@@ -1708,14 +2107,14 @@ export class ControlDatabase {
1708
2107
  /**
1709
2108
  * Re-vouch a live `OwnerKey` row under this owner's signature: in ONE transaction, the
1710
2109
  * current row's guarded delete plus tombstone ({@link deleteOwnerKey}'s body) and an insert
1711
- * of the same key under a fresh stamp, signed by `ownerKey` ({@link insertOwnerKeyVouched}'s
1712
- * body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
2110
+ * of the same key and label under a fresh stamp, signed by `ownerKey`
2111
+ * ({@link insertOwnerKeyVouched}'s body), as {@link rewritePendingJoin} re-seats a request. `OwnerKey` has `NoUpdate`, so
1713
2112
  * this is the only way to rebind a row's voucher; the anchor derivation lets the re-add
1714
2113
  * under a fresh stamp beat the older tombstone (`deriveOwnerAnchor`). For
1715
- * {@link removeMachine}: the owners a machine being removed had added keep their anchor
1716
- * place everywhere once their row stands on a remaining owner's signature.
2114
+ * `CadreNode.removeOwner`: the owners a key being removed had added keep their anchor place
2115
+ * everywhere once their row stands on a remaining owner's signature.
1717
2116
  *
1718
- * Through {@link mutateCadrePeer}, the membership hub. The stamp read runs inside the locked
2117
+ * Through {@link mutateCadrePeer}, the membership hub. The row read runs inside the locked
1719
2118
  * body; a row whose stamp is already retired (held without its delete) is reported as absent
1720
2119
  * rather than tombstoned twice.
1721
2120
  *
@@ -1727,59 +2126,55 @@ export class ControlDatabase {
1727
2126
  throw new Error(`Cannot re-vouch owner key ${key}: an owner cannot re-vouch itself`);
1728
2127
  }
1729
2128
  return await this.mutateCadrePeer('owner-key-revouch', async () => {
1730
- // retry: false on both reads — inside the locked write body (see queryStampId's NOTE).
1731
- const stampId = await this.queryStampId('OwnerKey', key, false);
1732
- if (stampId === null || (await this.queryRevokedStamps('OwnerKey', false)).has(stampId)) {
2129
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2130
+ const live = (await this.queryOwnerKeyRows(false)).find((row) => row.key === key);
2131
+ if (live === undefined) {
1733
2132
  log('revouchOwnerKey: %s is not a live owner here (nothing to re-vouch)', key);
1734
2133
  return false;
1735
2134
  }
1736
- const removal = signGuardedRemoval(this.config.partyId, 'OwnerKey', key, stampId, signMessage);
1737
- const freshStampId = generateStampId(this.config.libp2pNode.peerId.toString());
1738
- const signature = signMessage(ownerKeyAddMessage(this.config.partyId, key, freshStampId));
2135
+ const removal = await signGuardedRemoval(this.config.partyId, 'OwnerKey', key, live.stampId, signMessage);
2136
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2137
+ const signature = await signMessage(ownerKeyAddMessage(this.config.partyId, key, live.label, stampId));
1739
2138
  await this.inTransaction('revouch OwnerKey', async () => {
1740
2139
  await this.execGuardedRemoval(removal, ownerKey);
1741
- await this.db.exec(`
1742
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig)
1743
- with context OwnerKey = ?, Signature = ?
1744
- values (?, ?, ?, ?)
1745
- `, [ownerKey, signature, key, freshStampId, ownerKey, signature]);
2140
+ await this.execOwnerKeyVouchedInsert({ key, label: live.label, stampId }, ownerKey, signature);
1746
2141
  });
1747
- log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, stampId);
2142
+ log('Owner key %s re-vouched by %s (stamp %s retired)', key, ownerKey, live.stampId);
1748
2143
  this.notifyGuardedDelete(removal.ref);
1749
2144
  return true;
1750
2145
  });
1751
2146
  }
1752
2147
  /**
1753
- * Remove one machine completely, in ONE transaction: its `CadrePeer` row, its `OwnerKey`
1754
- * row when `target.ownerKey` names one, its `DeviceToken` row when it has one, each with
1755
- * the `Revocation` tombstone retiring its stamp ({@link deleteGuardedRow}'s statement pair,
1756
- * composed bare as {@link deleteStrandAndPartyKey} composes it), plus a withdrawal tombstone
1757
- * for every `CadreInvite` in `target.inviteKeys` that is still live here
1758
- * ({@link withdrawCadreInvite}'s body). One transaction so no reader sees the machine half
1759
- * removed: a row gone but its push token resolvable, or its owner key gone but an invitation
1760
- * it could redeem again still open.
2148
+ * Remove one machine completely, in ONE transaction: its `CadrePeer` row and its
2149
+ * `DeviceToken` row when it has one, each with the `Revocation` tombstone retiring its stamp
2150
+ * ({@link deleteGuardedRow}'s statement pair, composed bare as {@link deleteStrandAndPartyKey}
2151
+ * composes it), plus a withdrawal tombstone for every `CadreInvite` in `target.inviteKeys`
2152
+ * that is still live here ({@link withdrawCadreInvite}'s body). One transaction so no reader
2153
+ * sees the machine half removed: a row gone but its push token resolvable, or an invitation
2154
+ * it could redeem again still open. Owner keys are never touched: they are the person's, and
2155
+ * removing one is {@link deleteOwnerKey}.
1761
2156
  *
1762
2157
  * Through {@link mutateCadrePeer}, the membership hub. Every stamp is read inside the locked
1763
2158
  * body, and a row whose stamp is already retired (held without its delete) is skipped rather
1764
- * than tombstoned twice. The by-name owner refusals of {@link deleteOwnerKey} apply to the
1765
- * owner-key arm. Nothing to remove writes nothing and returns all false.
2159
+ * than tombstoned twice. Nothing to remove writes nothing and returns all false.
1766
2160
  *
1767
- * The caller decides WHAT to remove, which owner key is the machine's and which invitations
1768
- * it could use, from its plan (`CadreNode.planMachineRemoval`); this method only refuses to
1769
- * write an inconsistent set.
2161
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
2162
+ * stamp and signature is minted inside the body, and the invitation keys come from the
2163
+ * caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
2164
+ * since collides on `Revocation`'s primary key — a constraint failure, never retried, as
2165
+ * before). A lost update after a sibling tree of the transaction already synced arrives as a
2166
+ * partial commit and stays declined.
1770
2167
  *
1771
- * @throws when `target.ownerKey` is `ownerKey`, or is the only live owner here.
2168
+ * The caller decides which invitations the machine could use, from its plan
2169
+ * (`CadreNode.planMachineRemoval`).
1772
2170
  */
1773
2171
  async removeMachine(target, ownerKey, signMessage) {
1774
- if (target.ownerKey === ownerKey) {
1775
- throw new Error(`Cannot remove owner key ${ownerKey}: an owner cannot remove itself`);
1776
- }
1777
2172
  return await this.mutateCadrePeer('machine-remove', async () => {
1778
- const removals = await this.signMachineRemovals(target, signMessage);
1779
- const withdrawals = await this.signCadreInviteWithdrawals(target.inviteKeys, signMessage);
2173
+ const removals = await this.signMachineRemovals(target.peerId, signMessage);
2174
+ const withdrawals = await this.signWithdrawals('CadreInvite', target.inviteKeys, signMessage);
1780
2175
  if (removals.length === 0 && withdrawals.length === 0) {
1781
2176
  log('removeMachine: nothing live names %s here (already removed)', target.peerId);
1782
- return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [] };
2177
+ return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
1783
2178
  }
1784
2179
  await this.inTransaction('remove machine', async () => {
1785
2180
  for (const removal of removals) {
@@ -1799,19 +2194,18 @@ export class ControlDatabase {
1799
2194
  }
1800
2195
  return {
1801
2196
  removedPeer: removed.has('CadrePeer'),
1802
- removedOwnerKey: removed.has('OwnerKey'),
1803
2197
  removedDeviceToken: removed.has('DeviceToken'),
1804
2198
  withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
1805
2199
  };
1806
- });
2200
+ }, LOST_UPDATE_RETRY_POLICY);
1807
2201
  }
1808
2202
  /**
1809
2203
  * The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
1810
- * the `CadrePeer` row, the `OwnerKey` row when named, the `DeviceToken` row when present.
1811
- * Inside the locked body (every read `retry: false`); one `Revocation` scan serves the
1812
- * retired-stamp check for all three tables and the last-owner refusal.
2204
+ * the `CadrePeer` row and the `DeviceToken` row, each when present. Inside the locked body
2205
+ * (every read `retry: false`); one `Revocation` scan serves the retired-stamp check for both
2206
+ * tables.
1813
2207
  */
1814
- async signMachineRemovals(target, signMessage) {
2208
+ async signMachineRemovals(peerId, signMessage) {
1815
2209
  const retiredByTable = new Map();
1816
2210
  for (const tombstone of await this.queryRevocations(false)) {
1817
2211
  const stamps = retiredByTable.get(tombstone.tableName) ?? new Set();
@@ -1824,24 +2218,12 @@ export class ControlDatabase {
1824
2218
  return stampId !== null && !retired(table).has(stampId) ? stampId : null;
1825
2219
  };
1826
2220
  const removals = [];
1827
- const peerStamp = await liveStamp('CadrePeer', target.peerId);
1828
- if (peerStamp !== null) {
1829
- removals.push(signGuardedRemoval(this.config.partyId, 'CadrePeer', target.peerId, peerStamp, signMessage));
1830
- }
1831
- if (target.ownerKey !== null) {
1832
- const ownerStamp = await liveStamp('OwnerKey', target.ownerKey);
1833
- if (ownerStamp !== null) {
1834
- const owners = await this.queryOwnerKeyRows(false, retired('OwnerKey'));
1835
- if (owners.length <= 1) {
1836
- throw new Error(`Cannot remove owner key ${target.ownerKey}: cannot remove the last owner`);
1837
- }
1838
- removals.push(signGuardedRemoval(this.config.partyId, 'OwnerKey', target.ownerKey, ownerStamp, signMessage));
2221
+ for (const table of ['CadrePeer', 'DeviceToken']) {
2222
+ const stampId = await liveStamp(table, peerId);
2223
+ if (stampId !== null) {
2224
+ removals.push(await signGuardedRemoval(this.config.partyId, table, peerId, stampId, signMessage));
1839
2225
  }
1840
2226
  }
1841
- const tokenStamp = await liveStamp('DeviceToken', target.peerId);
1842
- if (tokenStamp !== null) {
1843
- removals.push(signGuardedRemoval(this.config.partyId, 'DeviceToken', target.peerId, tokenStamp, signMessage));
1844
- }
1845
2227
  return removals;
1846
2228
  }
1847
2229
  /**
@@ -1866,23 +2248,70 @@ export class ControlDatabase {
1866
2248
  async insertStrand(strandId, type, ownerKey, signMessage, memberPrivateKey) {
1867
2249
  this.ensureInitialized();
1868
2250
  log('Inserting strand: %s (type: %s)', strandId, type);
2251
+ const insert = await this.signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey);
2252
+ await this.execWrite(insert.sql, insert.params, 'strand-insert');
2253
+ log('Strand inserted: %s', strandId);
2254
+ }
2255
+ /** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
2256
+ async signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey) {
1869
2257
  // Generate a unique stamp ID using the peer ID for distributed uniqueness
1870
2258
  const peerId = this.config.libp2pNode.peerId.toString();
1871
2259
  const stampId = generateStampId(peerId);
1872
2260
  // Field order MUST match the schema's Strand `AuthorizedInsert` verify:
1873
- // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
2261
+ // Id, Type, MemberPrivateKey ('' when null), FounderPeerId, StampId. FounderOwnerKey is
1874
2262
  // deliberately NOT in the digest — the schema binds it by equality to the
1875
2263
  // verified context.OwnerKey instead (see the constraint's comment).
1876
- const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', stampId]);
1877
- const signature = signMessage(message);
2264
+ const message = buildAuthorizationMessage('CadreControl.Strand', 'add', this.config.partyId, [strandId, type, memberPrivateKey ?? '', peerId, stampId]);
2265
+ const signature = await signMessage(message);
1878
2266
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
1879
2267
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
1880
- await this.execWrite(`
1881
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
1882
- with context OwnerKey = ?, Signature = ?
1883
- values (?, ?, ?, ?, ?)
1884
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
1885
- log('Strand inserted: %s', strandId);
2268
+ return {
2269
+ sql: `
2270
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2271
+ with context OwnerKey = ?, Signature = ?
2272
+ values (?, ?, ?, ?, ?, ?)
2273
+ `,
2274
+ params: [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId],
2275
+ };
2276
+ }
2277
+ /**
2278
+ * Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
2279
+ * row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
2280
+ * never a strand row without the key or invitation written beside it.
2281
+ *
2282
+ * Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
2283
+ * {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
2284
+ * invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
2285
+ * taken: signing may prompt the user, and the lock held across a prompt would stall every
2286
+ * other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
2287
+ *
2288
+ * No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
2289
+ * `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
2290
+ * `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
2291
+ *
2292
+ * A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
2293
+ * again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
2294
+ * stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
2295
+ */
2296
+ async insertFoundingRows(rows, ownerKey, signMessage) {
2297
+ this.ensureInitialized();
2298
+ const { strand, partyKey, formationInvite } = rows;
2299
+ log('Inserting founding rows: strand %s (type: %s, party key: %s, invitation: %s)', strand.id, strand.type, partyKey !== undefined, formationInvite !== undefined);
2300
+ const inserts = [await this.signStrandInsert(strand.id, strand.type, ownerKey, signMessage, strand.memberPrivateKey)];
2301
+ if (partyKey !== undefined) {
2302
+ inserts.push(await this.signStrandPartyKeyInsert(strand.id, partyKey, ownerKey, signMessage));
2303
+ }
2304
+ if (formationInvite) {
2305
+ const { token, sAppId, ...options } = formationInvite;
2306
+ inserts.push(await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, { ...options, strandId: strand.id }));
2307
+ }
2308
+ // Bare `exec`: the lock is held, and `execWrite` would take it again and self-deadlock.
2309
+ await this.lockedWithRetry(() => this.inTransaction('found strand', async () => {
2310
+ for (const insert of inserts) {
2311
+ await this.db.exec(insert.sql, insert.params);
2312
+ }
2313
+ }), {}, 'strand-found');
2314
+ log('Founding rows inserted: strand %s', strand.id);
1886
2315
  }
1887
2316
  /**
1888
2317
  * Delete a strand from the control database using an owner signature.
@@ -1935,10 +2364,10 @@ export class ControlDatabase {
1935
2364
  return false;
1936
2365
  }
1937
2366
  const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1938
- const strandRemoval = signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
2367
+ const strandRemoval = await signGuardedRemoval(this.config.partyId, 'Strand', strandId, strandStamp, signMessage);
1939
2368
  const partyKeyRemoval = partyKeyStamp === null
1940
2369
  ? null
1941
- : signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
2370
+ : await signGuardedRemoval(this.config.partyId, 'StrandPartyKey', strandId, partyKeyStamp, signMessage);
1942
2371
  await this.inTransaction('delete Strand', async () => {
1943
2372
  await this.execGuardedRemoval(strandRemoval, ownerKey);
1944
2373
  if (partyKeyRemoval !== null) {
@@ -1971,18 +2400,25 @@ export class ControlDatabase {
1971
2400
  async insertStrandPartyKey(strandId, privateKey, ownerKey, signMessage) {
1972
2401
  this.ensureInitialized();
1973
2402
  log('Inserting strand party key for strand: %s', strandId);
1974
- const peerId = this.config.libp2pNode.peerId.toString();
1975
- const stampId = generateStampId(peerId);
2403
+ const insert = await this.signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage);
2404
+ await this.execWrite(insert.sql, insert.params, 'strand-party-key-insert');
2405
+ log('Strand party key inserted for strand: %s', strandId);
2406
+ }
2407
+ /** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
2408
+ async signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage) {
2409
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
1976
2410
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
1977
2411
  // Id, PrivateKey, StampId.
1978
2412
  const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
1979
- const signature = signMessage(message);
1980
- await this.execWrite(`
1981
- insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
1982
- with context OwnerKey = ?, Signature = ?
1983
- values (?, ?, ?)
1984
- `, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
1985
- log('Strand party key inserted for strand: %s', strandId);
2413
+ const signature = await signMessage(message);
2414
+ return {
2415
+ sql: `
2416
+ insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
2417
+ with context OwnerKey = ?, Signature = ?
2418
+ values (?, ?, ?)
2419
+ `,
2420
+ params: [ownerKey, signature, strandId, privateKey, stampId],
2421
+ };
1986
2422
  }
1987
2423
  /**
1988
2424
  * Owner-signed removal of one `StrandPartyKey` row — the party's own membership
@@ -2018,7 +2454,7 @@ export class ControlDatabase {
2018
2454
  // Field order MUST match the schema's JoinedStrand `AuthorizedInsert` verify:
2019
2455
  // Id, Type, MemberPrivateKey ('' when null), StampId.
2020
2456
  const message = buildAuthorizationMessage('CadreControl.JoinedStrand', 'add', this.config.partyId, [row.Id, row.Type, row.MemberPrivateKey ?? '', stampId]);
2021
- const signature = signMessage(message);
2457
+ const signature = await signMessage(message);
2022
2458
  await this.execWrite(`
2023
2459
  insert into CadreControl.JoinedStrand (Id, Type, MemberPrivateKey, StampId)
2024
2460
  with context OwnerKey = ?, Signature = ?
@@ -2044,7 +2480,7 @@ export class ControlDatabase {
2044
2480
  */
2045
2481
  async insertJoinRequest(request, ownerKey, signMessage) {
2046
2482
  this.ensureInitialized();
2047
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2483
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2048
2484
  await this.execWrite(insert.sql, insert.params, 'join-request-insert');
2049
2485
  log('Join request inserted: %s', request.Id);
2050
2486
  return { ...written, outcome: null };
@@ -2064,7 +2500,7 @@ export class ControlDatabase {
2064
2500
  */
2065
2501
  async recordJoinOutcome(expected, outcome, ownerKey, signMessage) {
2066
2502
  this.ensureInitialized();
2067
- const insert = signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2503
+ const insert = await signJoinOutcomeInsert(this.config.partyId, expected.StampId, outcome, ownerKey, signMessage);
2068
2504
  return this.lockedWithRetry(async () => {
2069
2505
  const live = await this.readPendingJoin(expected.Id, false);
2070
2506
  if (live === null || live.StampId !== expected.StampId || live.outcome?.kind !== expected.outcome?.kind) {
@@ -2097,9 +2533,9 @@ export class ControlDatabase {
2097
2533
  async rewritePendingJoin(expectedStampId, next, ownerKey, signMessage) {
2098
2534
  this.ensureInitialized();
2099
2535
  const { outcome, ...request } = next;
2100
- const removal = signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2101
- const { written, insert } = this.signJoinRequestInsert(request, ownerKey, signMessage);
2102
- const outcomeInsert = outcome === null ? null : signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2536
+ const removal = await signGuardedRemoval(this.config.partyId, 'JoinRequest', next.Id, expectedStampId, signMessage);
2537
+ const { written, insert } = await this.signJoinRequestInsert(request, ownerKey, signMessage);
2538
+ const outcomeInsert = outcome === null ? null : await signJoinOutcomeInsert(this.config.partyId, written.StampId, outcome, ownerKey, signMessage);
2103
2539
  return this.lockedWithRetry(async () => {
2104
2540
  // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
2105
2541
  const liveStampId = await this.queryStampId('JoinRequest', next.Id, false);
@@ -2132,9 +2568,9 @@ export class ControlDatabase {
2132
2568
  * Mint a fresh stamp for `request` and the owner signature `JoinRequest.AuthorizedInsert`
2133
2569
  * verifies, returning the row as it will be written and its insert.
2134
2570
  */
2135
- signJoinRequestInsert(request, ownerKey, signMessage) {
2571
+ async signJoinRequestInsert(request, ownerKey, signMessage) {
2136
2572
  const written = { ...request, StampId: generateStampId(this.config.libp2pNode.peerId.toString()) };
2137
- const signature = signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2573
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.JoinRequest', 'add', this.config.partyId, joinRequestAddFields(written)));
2138
2574
  const params = [ownerKey, signature, written.Id, written.Invitation, written.Disclosure, written.RequestedAt, written.ExpiresAt, written.StampId];
2139
2575
  return { written, insert: { sql: JOIN_REQUEST_INSERT_SQL, params } };
2140
2576
  }
@@ -2175,7 +2611,7 @@ export class ControlDatabase {
2175
2611
  const stampId = generateStampId(peerId);
2176
2612
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
2177
2613
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', this.config.partyId, [key, stampId]);
2178
- const signature = signMessage(message);
2614
+ const signature = await signMessage(message);
2179
2615
  await this.execWrite(`
2180
2616
  insert into CadreControl.ValidationKey (Key, StampId)
2181
2617
  with context OwnerKey = ?, Signature = ?
@@ -2244,7 +2680,7 @@ export class ControlDatabase {
2244
2680
  // Stamp is minted from the ADMITTED peer's id, not this node's — the nonce names the
2245
2681
  // row it vouches.
2246
2682
  const stampId = generateStampId(row.peerId);
2247
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
2683
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [row.peerId, stampId]));
2248
2684
  return await this.mutateCadrePeer('peer-insert', async () => {
2249
2685
  // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
2250
2686
  if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
@@ -2266,32 +2702,44 @@ export class ControlDatabase {
2266
2702
  });
2267
2703
  }
2268
2704
  /**
2269
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
2270
- * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
2271
- * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
2272
- * the row is re-emitted as a fresh, broadcasting transaction.
2273
- *
2274
- * This is the write-while-alone re-replication primitive: a membership row that committed
2275
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
2276
- * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
2277
- * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
2278
- * and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
2279
- * owner branch requires them kept: they are the machine's own claim) — so it is safe
2280
- * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
2281
- * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
2282
- * `UpdatedAt` without re-signing would invalidate its self-signature).
2283
- *
2284
- * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
2285
- * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
2286
- * membership snapshot cannot have changed.
2287
- *
2288
- * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
2289
- * that removes the row in between makes the `update` match nothing, and this still returns
2290
- * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
2291
- * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
2292
- * re-add in the same window fails loudly instead, since the signature binds the retired
2293
- * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
2294
- * into the locked body.
2705
+ * Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
2706
+ * (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
2707
+ * `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
2708
+ * {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
2709
+ * transaction, or stands on a different owner's voucher.
2710
+ *
2711
+ * Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
2712
+ * committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
2713
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
2714
+ * the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
2715
+ * stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
2716
+ * (the owner branch requires them kept: they are the machine's own claim) — so it is safe
2717
+ * over a row whose peer has not self-published (`Sig` null), and this method itself skips a
2718
+ * row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
2719
+ * bumping `UpdatedAt` without re-signing would invalidate its self-signature).
2720
+ *
2721
+ * The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
2722
+ * bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
2723
+ * only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
2724
+ * writes whether or not the machine has self-published (the owner branch has no monotonic
2725
+ * rule, so the column simply keeps its value).
2726
+ *
2727
+ * The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
2728
+ * row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
2729
+ * what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
2730
+ * the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
2731
+ * at the moment that publish does. A re-run after losing to it reads the self-signed row and
2732
+ * reports `'self-published'` instead of writing the owner branch over it — which would rewrite
2733
+ * `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
2734
+ * resolvers until the machine's next heartbeat. The decision has to be the body's for that;
2735
+ * a caller's pre-check outside the lock is only a fast path (see below).
2736
+ *
2737
+ * An absent row — none, or its stamp retired, or a stampless row no writer produces — is
2738
+ * `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
2739
+ * writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
2740
+ * ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
2741
+ * in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
2742
+ * which only re-reads the member set.
2295
2743
  *
2296
2744
  * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
2297
2745
  * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
@@ -2305,38 +2753,55 @@ export class ControlDatabase {
2305
2753
  *
2306
2754
  * NOTE: this rebinds VouchOwner to the CALLING owner's key, and the authorized-membership
2307
2755
  * predicate (`CadreNode.listAuthorizedMembers`) now judges rows by that column against each
2308
- * reader's node-local anchor. Benign today because the only caller — the write-while-alone
2309
- * drain — re-touches solely rows this node itself authored (`pendingPeerWrites`), so the
2310
- * voucher is rewritten to the key that already signed it. If a future path ever lets one
2311
- * owner re-touch a row a DIFFERENT owner vouched, the voucher silently flips: readers that
2312
- * anchor the original owner but not this one would drop a legitimate member. Such a path
2313
- * must re-vouch deliberately (or preserve the existing VouchOwner/VouchSig) rather than
2314
- * inherit this rebinding.
2756
+ * reader's node-local anchor. The two callers rebind on purpose: the write-while-alone drain
2757
+ * re-touches solely rows this node itself authored (`pendingPeerWrites`), so the voucher is
2758
+ * rewritten to the key that already signed it, and `CadreNode.removeOwner` moves rows off a
2759
+ * key it is about to remove. A new caller that re-touches a row a DIFFERENT owner vouched
2760
+ * flips the voucher silently: readers that anchor the original owner but not this one would
2761
+ * drop a legitimate member. It must re-vouch deliberately (or preserve the existing
2762
+ * VouchOwner/VouchSig) rather than inherit this rebinding.
2315
2763
  *
2316
2764
  * @param peerId - the membership row to re-touch.
2317
- * @param updatedAt - the strictly-increasing freshness stamp to write.
2765
+ * @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
2318
2766
  * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
2319
- * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
2320
- * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
2767
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
2768
+ * called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
2769
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}).
2321
2770
  */
2322
- async reauthorizeCadrePeer(peerId, updatedAt, ownerKey, signMessage) {
2771
+ async reauthorizeCadrePeer(peerId, mode, ownerKey, signMessage) {
2323
2772
  this.ensureInitialized();
2324
- const stampId = await this.queryStampId('CadrePeer', peerId);
2325
- if (stampId === null) {
2326
- log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
2327
- return false;
2773
+ const outside = retouchTarget(await this.findCadrePeerRow(peerId, true), mode);
2774
+ if (typeof outside === 'string') {
2775
+ log('reauthorizeCadrePeer(%s): %s for %s (nothing to write)', mode, outside, peerId);
2776
+ return outside;
2328
2777
  }
2329
- const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
2330
- await this.mutateCadrePeer('peer-reauthorize', async () => {
2778
+ return await this.mutateCadrePeer('peer-reauthorize', async () => {
2779
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2780
+ const target = retouchTarget(await this.findCadrePeerRow(peerId, false), mode);
2781
+ if (typeof target === 'string') {
2782
+ log('reauthorizeCadrePeer(%s): %s for %s since the pre-check (nothing to write)', mode, target, peerId);
2783
+ return target;
2784
+ }
2785
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, target.stampId]));
2331
2786
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2332
- await this.db.exec(`
2333
- update CadreControl.CadrePeer
2334
- with context OwnerKey = ?, Signature = ?
2335
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2336
- where PeerId = ?
2337
- `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
2338
- });
2339
- return true;
2787
+ if (mode === 'rebroadcast') {
2788
+ await this.db.exec(`
2789
+ update CadreControl.CadrePeer
2790
+ with context OwnerKey = ?, Signature = ?
2791
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2792
+ where PeerId = ?
2793
+ `, [ownerKey, signature, Math.max(Date.now(), target.updatedAt + 1), ownerKey, signature, peerId]);
2794
+ }
2795
+ else {
2796
+ await this.db.exec(`
2797
+ update CadreControl.CadrePeer
2798
+ with context OwnerKey = ?, Signature = ?
2799
+ set VouchOwner = ?, VouchSig = ?, VouchUsage = null
2800
+ where PeerId = ?
2801
+ `, [ownerKey, signature, ownerKey, signature, peerId]);
2802
+ }
2803
+ return 'retouched';
2804
+ }, LOST_UPDATE_RETRY_POLICY);
2340
2805
  }
2341
2806
  /**
2342
2807
  * Owner-signed removal of one `CadrePeer` membership row, notifying the membership
@@ -2357,9 +2822,15 @@ export class ControlDatabase {
2357
2822
  *
2358
2823
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
2359
2824
  * when a row was actually removed.
2825
+ *
2826
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
2827
+ * at the moment of the removal (its first publish lands about a second after it starts), and
2828
+ * the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
2829
+ * and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
2830
+ * binds, so the re-run reads the published row and removes it.
2360
2831
  */
2361
2832
  deleteCadrePeer(peerId, ownerKey, signMessage) {
2362
- return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
2833
+ return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage), LOST_UPDATE_RETRY_POLICY);
2363
2834
  }
2364
2835
  /**
2365
2836
  * Owner-signed removal of one peer's `DeviceToken` row (logout / token invalidation).
@@ -2412,7 +2883,7 @@ export class ControlDatabase {
2412
2883
  log('delete %s: no row for %s (already absent)', table, keyValue);
2413
2884
  return false;
2414
2885
  }
2415
- const removal = signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2886
+ const removal = await signGuardedRemoval(this.config.partyId, table, keyValue, stampId, signMessage);
2416
2887
  await this.inTransaction(`delete ${table}`, async () => {
2417
2888
  await alongside?.(stampId);
2418
2889
  await this.execGuardedRemoval(removal, ownerKey);
@@ -2445,10 +2916,9 @@ export class ControlDatabase {
2445
2916
  }
2446
2917
  /**
2447
2918
  * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2448
- * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of a
2449
- * `CadreInvite` withdrawal ({@link withdrawCadreInvite}), whose row stays — or the ledger
2450
- * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2451
- * lock and, where one is needed, the transaction.
2919
+ * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
2920
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
2921
+ * supplies the lock and, where one is needed, the transaction.
2452
2922
  *
2453
2923
  * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
2454
2924
  * than leaning on the column default — the seat-at-zero rule is load-bearing for
@@ -2502,9 +2972,10 @@ export class ControlDatabase {
2502
2972
  * statement, and without the clause the reap would delete the owner's brand-new row.
2503
2973
  * With it the statement matches nothing and the reap is a silent no-op (the schema
2504
2974
  * branch would also refuse that delete, but as a constraint error thrown into a
2505
- * background sweep — matched-nothing is the better failure mode). Like
2506
- * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
2507
- * harmless — nothing was deleted, and the next sweep re-reads.
2975
+ * background sweep — matched-nothing is the better failure mode). A race lost AFTER the
2976
+ * guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
2977
+ * its lock and reports what it found); harmless — nothing was deleted, and the next sweep
2978
+ * re-reads.
2508
2979
  *
2509
2980
  * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
2510
2981
  * committed tombstone that authorizes the reap, so a reap files no second tombstone.
@@ -2531,7 +3002,7 @@ export class ControlDatabase {
2531
3002
  * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
2532
3003
  *
2533
3004
  * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
2534
- * reconcile pass runs while the node has at least one control connection.
3005
+ * reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
2535
3006
  */
2536
3007
  async reapRevokedRow(table, rowKey, stampId) {
2537
3008
  this.ensureInitialized();
@@ -2589,8 +3060,8 @@ export class ControlDatabase {
2589
3060
  * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
2590
3061
  * that converged on a removal while holding the removed row keeps that row as inert
2591
3062
  * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
2592
- * control-cohort reconcile pass is that something, gated on the node having at least one
2593
- * control connection (a reap is a write, and a write committed alone is local-only).
3063
+ * control-cohort reconcile pass is that something, gated on a sibling being reachable
3064
+ * (a reap is a write, and a write committed alone is local-only).
2594
3065
  *
2595
3066
  * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
2596
3067
  * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
@@ -2623,12 +3094,11 @@ export class ControlDatabase {
2623
3094
  *
2624
3095
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
2625
3096
  * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
2626
- * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
2627
- * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
2628
- * only while `Revocation` has never been written; the ledger marker
2629
- * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
3097
+ * the common case (a party that has never revoked anyone) a single scan per pass, served
3098
+ * from the `Revocation` membership view
3099
+ * ({@link readMembershipRows}) unless the table changed since the last read.
2630
3100
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
2631
- * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
3101
+ * lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
2632
3102
  * party; if the table ever grows, persist a node-local high-water mark of what has
2633
3103
  * already been reaped instead of re-scanning everything (the same bound the sweep in
2634
3104
  * `CadreNode.drainPendingRevocations` wants).
@@ -2692,17 +3162,20 @@ export class ControlDatabase {
2692
3162
  *
2693
3163
  * Returns how many UPDATE statements ran (`rows.length` on success).
2694
3164
  */
2695
- reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
3165
+ async reissueRevocations(rows, reissuedAt, ownerKey, signMessage) {
2696
3166
  this.ensureInitialized();
2697
3167
  // Signatures minted OUTSIDE the locked body: a retried attempt must re-present the
2698
3168
  // exact signed message, never re-mint (the contract on withWriteLock). ReissuedAt
2699
3169
  // rides in the digest as a string — SQL-side it is cast(new.ReissuedAt as text),
2700
3170
  // and every digest field is TEXT on both sides (see buildAuthorizationMessage).
2701
- const signed = rows.map((row) => ({
2702
- row,
2703
- signature: signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
2704
- }));
2705
- return this.lockedWithRetry(async () => {
3171
+ const signed = [];
3172
+ for (const row of rows) {
3173
+ signed.push({
3174
+ row,
3175
+ signature: await signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'reissue', this.config.partyId, [row.tableName, row.rowKey, row.stampId, String(reissuedAt)])),
3176
+ });
3177
+ }
3178
+ return await this.lockedWithRetry(async () => {
2706
3179
  // Counter lives INSIDE the locked body so a lockedWithRetry re-run (which
2707
3180
  // re-executes the whole body) starts from zero instead of double-counting.
2708
3181
  let executed = 0;
@@ -2733,69 +3206,6 @@ export class ControlDatabase {
2733
3206
  return executed;
2734
3207
  }, {}, 'revocation-reissue');
2735
3208
  }
2736
- /**
2737
- * Owner-signed filing of the singleton `Revocation` ledger marker
2738
- * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
2739
- *
2740
- * Why it exists: while `Revocation` has never been written, this node does not hold its
2741
- * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
2742
- * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
2743
- * reads this table. Once any row exists the block is held and re-checked at most once per
2744
- * read-repair window, like every other populated table. The schema's table comment says
2745
- * why the row can never read as a retirement.
2746
- *
2747
- * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
2748
- * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
2749
- * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
2750
- * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
2751
- * full composite primary key, which is served as a point lookup that can miss an existing
2752
- * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
2753
- *
2754
- * Two owners filing at once: the loser either sees the marker in its guard or is refused
2755
- * on the primary key, and both answer `'already-open'`.
2756
- *
2757
- * The caller decides WHEN. A marker committed while the node is alone is local-only and
2758
- * can fork the collection, so {@link CadreNode} files it only while connected; this
2759
- * method does not look at connectivity.
2760
- *
2761
- * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
2762
- * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
2763
- * private key, returning a base64url signature.
2764
- * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
2765
- * already there.
2766
- */
2767
- async openRevocationLedger(ownerKey, signMessage) {
2768
- this.ensureInitialized();
2769
- const signature = signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2770
- try {
2771
- return await this.lockedWithRetry(async () => {
2772
- if (await this.revocationLedgerFiled()) {
2773
- return 'already-open';
2774
- }
2775
- // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
2776
- await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
2777
- log('Revocation ledger marker filed');
2778
- return 'opened';
2779
- }, {}, 'revocation-ledger-open');
2780
- }
2781
- catch (error) {
2782
- if (!isRevocationLedgerConflict(error)) {
2783
- throw error;
2784
- }
2785
- log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
2786
- return 'already-open';
2787
- }
2788
- }
2789
- /**
2790
- * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
2791
- * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
2792
- * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
2793
- */
2794
- async revocationLedgerFiled() {
2795
- const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
2796
- const rows = await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revocation-ledger', false);
2797
- return rows.some(row => row.StampId === stampId);
2798
- }
2799
3209
  /**
2800
3210
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
2801
3211
  *
@@ -2843,10 +3253,26 @@ export class ControlDatabase {
2843
3253
  * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
2844
3254
  * second call replaces the first rather than fanning out, so a stale node can
2845
3255
  * never keep receiving notifications for a database it no longer owns.
3256
+ *
3257
+ * Only this database's own writes notify here, awaited inside the write so the gate is
3258
+ * current before the write returns. A membership change replicated in from another
3259
+ * machine reaches the gate through the subscription `CadreNode.start` takes on the
3260
+ * membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
3261
+ * lands in storage; one storage never hears of waits for the reconcile pass's backstop.
2846
3262
  */
2847
3263
  setMembershipChangeListener(listener) {
2848
3264
  this.membershipListener = listener;
2849
3265
  }
3266
+ /**
3267
+ * Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
3268
+ * made here and changes replicated into this node's storage alike, on the single-flight
3269
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
3270
+ * still live before it drains the write queue.
3271
+ */
3272
+ watchTables(tables, onChange, options) {
3273
+ this.ensureInitialized();
3274
+ return this.tableWatches.watch(this.db, tables, onChange, options);
3275
+ }
2850
3276
  /**
2851
3277
  * Wire (or clear, with null) the single listener notified after every committed
2852
3278
  * guarded-table delete. Same ownership contract as
@@ -2876,8 +3302,9 @@ export class ControlDatabase {
2876
3302
  * Covers every write that can change who is a member or an owner: EVERY `CadrePeer`
2877
3303
  * writer, with one documented exception ({@link updateSelfPeerRecord}, which cannot
2878
3304
  * change the snapshot), the `OwnerKey` writers ({@link insertOwnerKeyVouched},
2879
- * {@link deleteOwnerKey}) and the invitation redemption ({@link redeemCadreInvite}),
2880
- * which seats both. That is what makes the party-membership snapshot refresh automatic
3305
+ * {@link deleteOwnerKey}), the invitation redemption ({@link redeemCadreInvite}), which
3306
+ * seats both, and the owner-grant redemption ({@link redeemOwnerGrant}). That is what
3307
+ * makes the party-membership snapshot refresh automatic
2881
3308
  * rather than a caller obligation. The name predates the `OwnerKey` writers. A
2882
3309
  * writer necessarily holds the target node's ControlDatabase (the `SeedBootstrapService`'s
2883
3310
  * event callbacks are NOT a viable seam: the temp service `CadreNode.applySeed`
@@ -2904,8 +3331,11 @@ export class ControlDatabase {
2904
3331
  * exhaustion.
2905
3332
  *
2906
3333
  * @param reason - Label for the log line only (e.g. `'peer-insert'`).
3334
+ * @param policy - The retry policy, default unless the body has earned a wider one: the three
3335
+ * `CadrePeer` writers whose bodies re-read the row and decide inside pass
3336
+ * `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
2907
3337
  */
2908
- async mutateCadrePeer(reason, body) {
3338
+ async mutateCadrePeer(reason, body, policy = {}) {
2909
3339
  this.ensureInitialized();
2910
3340
  return this.lockedWithRetry(async () => {
2911
3341
  this.assertCommitBoundary(reason, 'on entry to');
@@ -2913,7 +3343,7 @@ export class ControlDatabase {
2913
3343
  this.assertCommitBoundary(reason, 'on return from');
2914
3344
  await this.notifyMembershipChanged(reason);
2915
3345
  return result;
2916
- }, {}, reason);
3346
+ }, policy, reason);
2917
3347
  }
2918
3348
  /**
2919
3349
  * Run one local write under the database-wide write lock, serializing it against
@@ -2927,8 +3357,8 @@ export class ControlDatabase {
2927
3357
  * class: every public write method of this class runs its statement(s) under it, and
2928
3358
  * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
2929
3359
  * `Database` directly, so it is covered too. The race is
2930
- * real, not theoretical: the background self-record publish on a control connection
2931
- * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
3360
+ * real, not theoretical: the background self-record publish when a sibling becomes
3361
+ * reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
2932
3362
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
2933
3363
  * assert.
2934
3364
  *
@@ -2952,6 +3382,11 @@ export class ControlDatabase {
2952
3382
  * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
2953
3383
  * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
2954
3384
  * attempt: a retry re-presents the exact signed message the first attempt presented.
3385
+ * A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
3386
+ * its re-run is a re-decision over a row another writer changed, not a re-presentation of
3387
+ * the same write, so its read of the row, its decision and its signature all run in the
3388
+ * body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
3389
+ * under {@link deleteCadrePeer}, {@link removeMachine}).
2955
3390
  */
2956
3391
  async withWriteLock(fn) {
2957
3392
  // Chain behind the current tail regardless of how it settled, then park a
@@ -2988,13 +3423,18 @@ export class ControlDatabase {
2988
3423
  * backoff never stalls the other local writers queued behind it. Safe because every
2989
3424
  * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
2990
3425
  *
2991
- * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
3426
+ * Also carries {@link applyControlSchema}'s distributed DDL, the one caller that runs BEFORE
2992
3427
  * `initialized` is set — so it reaches this method directly rather than through
2993
3428
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
2994
3429
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
2995
- * rather than carving out a lockless path. It is also the ONLY caller that passes a
2996
- * `policy` — everything else takes the default, whose classifier and attempt count are
2997
- * unchanged by that call site existing.
3430
+ * rather than carving out a lockless path.
3431
+ *
3432
+ * Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
3433
+ * call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
3434
+ * row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
3435
+ * {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
3436
+ * policy parameter). Everything else takes the default, whose classifier and attempt count
3437
+ * are unchanged by those call sites existing.
2998
3438
  *
2999
3439
  * `label` names the operation in this loop's debug lines and nothing else
3000
3440
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
@@ -3125,6 +3565,15 @@ export class ControlDatabase {
3125
3565
  async insertFormationInvite(token, sAppId, ownerKey, signMessage, options = {}) {
3126
3566
  this.ensureInitialized();
3127
3567
  log('Inserting formation invite: %s', token);
3568
+ const insert = await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options);
3569
+ await this.execWrite(insert.sql, insert.params, 'formation-invite-insert');
3570
+ log('Formation invite inserted: %s', token);
3571
+ }
3572
+ /**
3573
+ * The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
3574
+ * {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
3575
+ */
3576
+ async signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options) {
3128
3577
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
3129
3578
  // Build each bound field exactly as the deferred CHECK sees it post-coercion:
3130
3579
  // - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
@@ -3144,24 +3593,26 @@ export class ControlDatabase {
3144
3593
  const message = buildAuthorizationMessage('CadreControl.FormationInvite', 'add', this.config.partyId, [
3145
3594
  token, sAppId, expiresAtField, totalUsesField, validationUrlField, strandIdField, stampId,
3146
3595
  ]);
3147
- const signature = signMessage(message);
3596
+ const signature = await signMessage(message);
3148
3597
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
3149
3598
  // signed source-of-truth and the stored value are produced once. StampId is a real,
3150
3599
  // unique column (single-use anti-replay), no longer a context value.
3151
- await this.execWrite(`
3152
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3153
- with context OwnerKey = ?, Signature = ?
3154
- values (?, ?, ?, ?, ?, ?, ?)
3155
- `, [
3156
- ownerKey, signature,
3157
- token, sAppId,
3158
- expiresAtCanonical,
3159
- options.totalUses ?? null,
3160
- options.validationUrl ?? null,
3161
- options.strandId ?? null,
3162
- stampId,
3163
- ], 'formation-invite-insert');
3164
- log('Formation invite inserted: %s', token);
3600
+ return {
3601
+ sql: `
3602
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3603
+ with context OwnerKey = ?, Signature = ?
3604
+ values (?, ?, ?, ?, ?, ?, ?)
3605
+ `,
3606
+ params: [
3607
+ ownerKey, signature,
3608
+ token, sAppId,
3609
+ expiresAtCanonical,
3610
+ options.totalUses ?? null,
3611
+ options.validationUrl ?? null,
3612
+ options.strandId ?? null,
3613
+ stampId,
3614
+ ],
3615
+ };
3165
3616
  }
3166
3617
  /**
3167
3618
  * Redeem a `FormationInvite` by inserting the `Strand` row and a matching
@@ -3217,13 +3668,13 @@ export class ControlDatabase {
3217
3668
  await this.inTransaction('redemption', async () => {
3218
3669
  // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
3219
3670
  // still carrying a fresh unique StampId for the anti-replay column.
3220
- // Hard-coded open + keyless + explicit null FounderOwnerKey: the
3671
+ // Hard-coded open + keyless + explicit null founder columns: the
3221
3672
  // consent branch admits no other shape (no signature means no
3222
- // trustworthy founder machine to record — see the schema's NOTE).
3673
+ // trustworthy founder machine to record — see the schema's comment).
3223
3674
  await this.db.exec(`
3224
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
3675
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
3225
3676
  with context OwnerKey = null, Signature = null
3226
- values (?, 'o', null, ?, null)
3677
+ values (?, 'o', null, ?, null, null)
3227
3678
  `, [strandId, strandStampId]);
3228
3679
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
3229
3680
  // carrying the strand's stamp so it authorizes THIS row and no other.
@@ -3248,14 +3699,13 @@ export class ControlDatabase {
3248
3699
  * Use {@link redeemInvitation} instead when the strand must be created by
3249
3700
  * consent atomically with the usage.
3250
3701
  *
3251
- * The strand's live `StampId` is read first and written onto the usage row:
3702
+ * The strand's live `StampId` is written onto the usage row:
3252
3703
  * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
3253
3704
  * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
3254
- * record authorizes exactly the strand ROW it was recorded against. A missing strand
3255
- * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
3256
- * ordinary "host strand has not converged yet" case is already reported as `missing`
3257
- * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
3258
- * point is a genuine race and deserves a named error, not a silent rollback.
3705
+ * record authorizes exactly the strand ROW it was recorded against. A caller that already
3706
+ * read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
3707
+ * missing strand THROWS {@link MissingHostStrandError} rather than being left to the
3708
+ * deferred `StrandExists` CHECK.
3259
3709
  *
3260
3710
  * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
3261
3711
  * path — the one production actually races on (a bound invite published by `cadre-web` /
@@ -3269,7 +3719,7 @@ export class ControlDatabase {
3269
3719
  const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
3270
3720
  // The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
3271
3721
  // consent record authorizes.
3272
- const strandStampId = await this.queryStrandStampId(strandId);
3722
+ const strandStampId = params.strandStampId ?? await this.queryStrandStampId(strandId);
3273
3723
  if (strandStampId === null) {
3274
3724
  throw new MissingHostStrandError(strandId, token);
3275
3725
  }
@@ -3390,7 +3840,7 @@ export class ControlDatabase {
3390
3840
  * permissive PRE-check that runs ahead of it, and a transiently short read costs each of
3391
3841
  * them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
3392
3842
  * {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
3393
- * generic refusal; `ControlFormationUsageRecorder.isTokenUsed` reports not-used and lets
3843
+ * generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
3394
3844
  * the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
3395
3845
  * holds the stranger-admission door open slightly longer.
3396
3846
  *
@@ -3423,12 +3873,12 @@ export class ControlDatabase {
3423
3873
  /**
3424
3874
  * Is any `FormationInvite` row still redeemable — unexpired AND with usage
3425
3875
  * below its `TotalUses`? A null `ExpiresAt` never expires and a null
3426
- * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
3427
- * per-token semantics (`isTokenValid` / `isTokenUsed`).
3876
+ * `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
3877
+ * (`formation-token.ts`).
3428
3878
  *
3429
3879
  * Answers the control-network connection gate's coarse "does this node expect
3430
3880
  * a stranger?" question, which has no token to ask about. The expiry
3431
- * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
3881
+ * comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
3432
3882
  * invite the formation handler would reject can never hold the gate open.
3433
3883
  *
3434
3884
  * The scan is deliberately not pushed into SQL: nothing else here compares a
@@ -3492,73 +3942,116 @@ export class ControlDatabase {
3492
3942
  * private half to the invitee out of band. It is never stored here (`Strand.Invite` has the
3493
3943
  * same shape).
3494
3944
  *
3495
- * @param invite.key - the invitation public key (base64url ed25519), the row's primary key
3496
- * @param invite.peerId - the device the invitation is for, or null for whoever redeems first
3497
- * @param invite.grantsOwner - whether redemption also seats an `OwnerKey` row for the device
3498
- * @param invite.expiresAtMs - epoch ms after which it cannot be redeemed; omit for never
3499
- * @param invite.totalUses - how many redemptions it allows; omit for unlimited
3500
3945
  * @returns the row as stored, `expiresAt` in its canonical form
3501
3946
  */
3502
3947
  async insertCadreInvite(invite, ownerKey, signMessage) {
3948
+ const row = await this.signCadreInvite(invite, ownerKey, signMessage);
3949
+ await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
3950
+ log('Cadre invitation inserted: %s (peer=%s)', row.key, row.peerId ?? 'any');
3951
+ return row;
3952
+ }
3953
+ /**
3954
+ * Build and owner-sign a `CadreInvite` row WITHOUT inserting it: the signing half of
3955
+ * {@link insertCadreInvite}, which `CadreNode.joinWithOwnerGrant` uses alone for the
3956
+ * invitation a node issues to itself, since a store that holds no `OwnerKey` row for the
3957
+ * issuer would refuse the local insert and the row is seated at a member from the bundle
3958
+ * instead. `ExpiresAt` is canonicalized by this database's engine, which is a format, not a
3959
+ * store, so the row reads the same wherever it is seated.
3960
+ */
3961
+ async signCadreInvite(invite, ownerKey, signMessage) {
3503
3962
  this.ensureInitialized();
3504
3963
  const key = requireEd25519PublicKeyB64(invite.key, 'cadre invitation key');
3505
3964
  const signed = {
3506
3965
  key,
3507
3966
  peerId: invite.peerId ?? null,
3508
- grantsOwner: invite.grantsOwner,
3509
3967
  expiresAt: invite.expiresAtMs == null ? null : await canonicalDatetime(this.db, invite.expiresAtMs),
3510
3968
  totalUses: invite.totalUses ?? null,
3511
3969
  stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
3512
3970
  };
3513
- const row = {
3971
+ return {
3514
3972
  ...signed,
3515
3973
  issuerKey: ownerKey,
3516
- issuerSig: signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
3974
+ issuerSig: await signMessage(cadreInviteAddMessage(this.config.partyId, signed)),
3517
3975
  };
3518
- await this.lockedWithRetry(() => this.execCadreInviteInsert(row), {}, 'cadre-invite-insert');
3519
- log('Cadre invitation inserted: %s (peer=%s owner=%s)', key, row.peerId ?? 'any', row.grantsOwner);
3520
- return row;
3521
3976
  }
3522
3977
  /**
3523
3978
  * Seat an owner-signed `CadreInvite` row received inside an invitation bundle, under its own
3524
3979
  * stored `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, so a member that
3525
- * already holds the row (by replication, or from an earlier redemption attempt) is a no-op.
3980
+ * already holds the row (by replication, or from an earlier redemption attempt) is a no-op,
3981
+ * and so is one whose insert the cohort refuses because a member there holds it.
3526
3982
  *
3527
3983
  * @returns `true` when this call inserted the row, `false` when it was already present.
3528
3984
  * @throws {CadreInviteIssuerUnknownError} when `CadreInvite.AuthorizedInsert` refuses it: the
3529
3985
  * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
3530
- * not verify. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3986
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
3987
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
3531
3988
  */
3532
3989
  async seatCadreInvite(row) {
3990
+ return this.seatIssuedRow('CadreInvite', row.key, () => this.execCadreInviteInsert(row), cause => new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause }));
3991
+ }
3992
+ /**
3993
+ * Insert-if-absent of an issued row from a bundle's copy, under the write lock: the body of
3994
+ * {@link seatCadreInvite} and {@link seatOwnerGrant}. `exec` runs the bare insert under the
3995
+ * row's own stored issuer pair.
3996
+ *
3997
+ * @returns `true` when this call inserted the row, `false` when it was already present —
3998
+ * here, or at the cohort member that refused the insert for holding it
3999
+ * ({@link adoptCohortHeldRow}).
4000
+ * @throws `refuse(cause)` when the table's `AuthorizedInsert` refuses the row;
4001
+ * {@link IssuedRowNotHereYetError} when the cohort holds the row and this node still cannot
4002
+ * read it; every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4003
+ */
4004
+ async seatIssuedRow(table, key, exec, refuse) {
3533
4005
  this.ensureInitialized();
3534
4006
  try {
3535
4007
  return await this.lockedWithRetry(async () => {
3536
4008
  // retry: false — inside the locked write body (see queryStampId's NOTE).
3537
- if (await this.queryStampId('CadreInvite', row.key, false) !== null) {
3538
- log('CadreInvite %s already seated; seat skipped', row.key);
4009
+ if (await this.queryStampId(table, key, false) !== null) {
4010
+ log('%s %s already seated; seat skipped', table, key);
3539
4011
  return false;
3540
4012
  }
3541
- await this.execCadreInviteInsert(row);
3542
- log('Cadre invitation seated from its bundle: %s', row.key);
4013
+ await exec();
4014
+ log('%s seated from its bundle: %s', table, key);
3543
4015
  return true;
3544
- }, {}, 'cadre-invite-seat');
4016
+ }, {}, `${ISSUED_TABLE_REASON[table]}-seat`);
3545
4017
  }
3546
4018
  catch (error) {
3547
- if (errorChainMatches(error, CADRE_INVITE_UNAUTHORIZED)) {
3548
- throw new CadreInviteIssuerUnknownError(row.key, row.issuerKey, { cause: error });
4019
+ if (errorChainMatches(error, ISSUER_UNAUTHORIZED)) {
4020
+ throw refuse(error);
4021
+ }
4022
+ if (isIssuedRowKeyConflict(error, table)) {
4023
+ return await this.adoptCohortHeldRow(table, key, error);
3549
4024
  }
3550
4025
  throw error;
3551
4026
  }
3552
4027
  }
4028
+ /**
4029
+ * The seat's insert was refused because a cohort member already holds a row under `key`, which
4030
+ * this node's guard read missed because its store lags the cohort. A bundle's copy is verbatim
4031
+ * and every column is under the issuer's signature, so the held row is this one: it counts as
4032
+ * seated once a refreshing read (unlocked, so retried as any read) finds it here.
4033
+ *
4034
+ * @throws {IssuedRowNotHereYetError} when the read still does not find it.
4035
+ */
4036
+ async adoptCohortHeldRow(table, key, conflict) {
4037
+ // NOTE: while another locked body runs here, this read takes readRowsOnce's committed path,
4038
+ // which does not refresh from the cohort, so it can miss the row and refuse a retryable
4039
+ // `conflict`; if that refusal shows up in practice, wait for the running body before reading.
4040
+ if (await this.queryStampId(table, key) === null) {
4041
+ throw new IssuedRowNotHereYetError(table, key, { cause: conflict });
4042
+ }
4043
+ log('%s %s is held by the cohort; seat skipped', table, key);
4044
+ return false;
4045
+ }
3553
4046
  /** Bare `CadreInvite` insert shared by {@link insertCadreInvite} and {@link seatCadreInvite}; the caller holds the lock. */
3554
4047
  async execCadreInviteInsert(row) {
3555
4048
  await this.db.exec(`
3556
- insert into CadreControl.CadreInvite (Key, PeerId, GrantsOwner, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4049
+ insert into CadreControl.CadreInvite (Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
3557
4050
  with context OwnerKey = ?, Signature = ?
3558
- values (?, ?, ?, ?, ?, ?, ?, ?)
4051
+ values (?, ?, ?, ?, ?, ?, ?)
3559
4052
  `, [
3560
4053
  row.issuerKey, row.issuerSig,
3561
- row.key, row.peerId, row.grantsOwner ? 1 : 0, row.expiresAt, row.totalUses,
4054
+ row.key, row.peerId, row.expiresAt, row.totalUses,
3562
4055
  row.issuerKey, row.issuerSig, row.stampId,
3563
4056
  ]);
3564
4057
  }
@@ -3567,8 +4060,10 @@ export class ControlDatabase {
3567
4060
  * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
3568
4061
  * question, and `CadreInviteUsage.Authorized`'s at redemption.
3569
4062
  *
3570
- * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
3571
- * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4063
+ * A redemption reads the row once, before its write, and carries it into
4064
+ * {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
4065
+ * {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
4066
+ * the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
3572
4067
  */
3573
4068
  async queryCadreInvite(key, retry = true) {
3574
4069
  this.ensureInitialized();
@@ -3585,19 +4080,15 @@ export class ControlDatabase {
3585
4080
  * {@link queryCadrePeers}' reason.
3586
4081
  */
3587
4082
  async queryCadreInvites(retry = true) {
3588
- this.ensureInitialized();
3589
- const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
3590
- return rows.map(cadreInviteRowOf);
4083
+ return (await this.readMembershipRows(this.cadreInviteView, retry)).map(cadreInviteRowOf);
3591
4084
  }
3592
4085
  /**
3593
4086
  * Every `CadreInviteUsage` row this node holds — the redemption records the membership
3594
4087
  * predicate verifies invitation-admitted rows against. Same `retry` contract as
3595
- * {@link queryCadreInvites}.
4088
+ * {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
3596
4089
  */
3597
4090
  async queryCadreInviteUsages(retry = true) {
3598
- this.ensureInitialized();
3599
- const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
3600
- return rows.map(cadreInviteUsageRowOf);
4091
+ return (await this.readMembershipRows(this.cadreInviteUsageView, retry)).map(cadreInviteUsageRowOf);
3601
4092
  }
3602
4093
  /**
3603
4094
  * How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
@@ -3607,9 +4098,12 @@ export class ControlDatabase {
3607
4098
  * `retry: false` is passed by {@link assertCadreInviteSeatRemains}, inside the locked body.
3608
4099
  */
3609
4100
  async countCadreInviteUsage(key, retry = true) {
4101
+ return this.countUsageRows('select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?', key, 'cadre-invite-usage-count', retry);
4102
+ }
4103
+ /** The one-row count read behind {@link countCadreInviteUsage} and {@link countOwnerGrantUsage}. */
4104
+ async countUsageRows(sql, key, label, retry) {
3610
4105
  this.ensureInitialized();
3611
- const sql = 'select count(1) as Count from CadreControl.CadreInviteUsage where InviteKey = ?';
3612
- for (const row of await this.readRows(sql, [key], 'cadre-invite-usage-count', retry)) {
4106
+ for (const row of await this.readRows(sql, [key], label, retry)) {
3613
4107
  return row.Count ?? 0;
3614
4108
  }
3615
4109
  return 0;
@@ -3637,10 +4131,13 @@ export class ControlDatabase {
3637
4131
  // counts make that free today; if a long-lived cadre accumulates thousands and inbound
3638
4132
  // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
3639
4133
  // first needs every device they admitted re-vouched, so the chain no longer needs them).
4134
+ // NOTE: this scan reaches storage while queryCadreInvites serves the same rows from the
4135
+ // membership view; if the stranger path's reads ever near the gate's 2 s deadline, walk
4136
+ // queryCadreInvites() here instead.
3640
4137
  const metered = [];
3641
4138
  let unlimitedLive = false;
3642
4139
  for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
3643
- const open = cadreInviteStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
4140
+ const open = issuedRowStillOpen({ stampId: row.StampId, issuerKey: row.IssuerKey, expiresAt: row.ExpiresAt ?? null }, withdrawn, owners, nowMs);
3644
4141
  if (!open) {
3645
4142
  continue;
3646
4143
  }
@@ -3667,24 +4164,36 @@ export class ControlDatabase {
3667
4164
  }
3668
4165
  /**
3669
4166
  * Is this one cadre invitation still redeemable here — the conditions
3670
- * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
3671
- * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
3672
- * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
3673
- * judged by whichever owner it names as issuer. A permissive pre-check like
3674
- * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
3675
- * what enforce every condition.
4167
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
4168
+ * node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
4169
+ * none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
4170
+ * verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
4171
+ * issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
4172
+ * and the redemption's own write are what enforce every condition.
4173
+ *
4174
+ * The use count read here is read again inside the redemption's write lock
4175
+ * ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
4176
+ * pushed the control store before the write, so the holder of a spent invitation is refused
4177
+ * without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
3676
4178
  */
3677
- async isCadreInviteLive(invite, nowMs = Date.now()) {
4179
+ async isCadreInviteLive(invite, held, nowMs = Date.now()) {
3678
4180
  this.ensureInitialized();
3679
- const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
4181
+ const row = held ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
4182
+ return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
4183
+ }
4184
+ /**
4185
+ * The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
4186
+ * or a verified bundle copy; null for neither) still open and under its use cap?
4187
+ */
4188
+ async issuedRowLive(table, row, nowMs, countUsage) {
3680
4189
  if (row === null) {
3681
4190
  return false;
3682
4191
  }
3683
- const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()]);
3684
- if (!cadreInviteStillOpen(row, withdrawn, owners, nowMs)) {
4192
+ const [withdrawn, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
4193
+ if (!issuedRowStillOpen(row, withdrawn, owners, nowMs)) {
3685
4194
  return false;
3686
4195
  }
3687
- return row.totalUses === null || await this.countCadreInviteUsage(row.key) < row.totalUses;
4196
+ return row.totalUses === null || await countUsage(row.key) < row.totalUses;
3688
4197
  }
3689
4198
  /**
3690
4199
  * Every `CadreInvite` row this node holds with its standing: how many redemptions are
@@ -3694,22 +4203,28 @@ export class ControlDatabase {
3694
4203
  * occasional read, not a gate's.
3695
4204
  */
3696
4205
  async listCadreInviteStatuses(nowMs = Date.now()) {
4206
+ const standings = await this.issuedRowStandings('CadreInvite', await this.queryCadreInvites(), nowMs, key => this.countCadreInviteUsage(key));
4207
+ return standings.map(({ row, ...standing }) => ({ invite: row, ...standing }));
4208
+ }
4209
+ /**
4210
+ * The body of {@link listCadreInviteStatuses} and {@link listOwnerGrantStatuses}: each of
4211
+ * `rows` with its withdrawal, its recorded uses and whether it is still redeemable.
4212
+ */
4213
+ async issuedRowStandings(table, rows, nowMs, countUsage) {
3697
4214
  this.ensureInitialized();
3698
- const [invites, withdrawnStamps, owners] = await Promise.all([
3699
- this.queryCadreInvites(), this.queryRevokedStamps('CadreInvite'), this.getOwnerKeys()
3700
- ]);
3701
- const statuses = [];
3702
- // NOTE: one retried count read per invitation, withdrawn and expired rows included, since
3703
- // rows are never deleted; if a long-lived cadre's listing ever shows as slow, group the
3704
- // usage counts in one statement or skip the count for rows that are no longer open.
3705
- for (const invite of invites) {
3706
- const usesRecorded = await this.countCadreInviteUsage(invite.key);
3707
- const withdrawn = withdrawnStamps.has(invite.stampId);
3708
- const live = cadreInviteStillOpen(invite, withdrawnStamps, owners, nowMs)
3709
- && (invite.totalUses === null || usesRecorded < invite.totalUses);
3710
- statuses.push({ invite, live, withdrawn, usesRecorded });
4215
+ const [withdrawnStamps, owners] = await Promise.all([this.queryRevokedStamps(table), this.getOwnerKeys()]);
4216
+ const standings = [];
4217
+ // NOTE: one retried count read per row, withdrawn and expired rows included, since rows
4218
+ // are never deleted; if a long-lived cadre's listing ever shows as slow, group the usage
4219
+ // counts in one statement or skip the count for rows that are no longer open.
4220
+ for (const row of rows) {
4221
+ const usesRecorded = await countUsage(row.key);
4222
+ const withdrawn = withdrawnStamps.has(row.stampId);
4223
+ const live = issuedRowStillOpen(row, withdrawnStamps, owners, nowMs)
4224
+ && (row.totalUses === null || usesRecorded < row.totalUses);
4225
+ standings.push({ row, live, withdrawn, usesRecorded });
3711
4226
  }
3712
- return statuses;
4227
+ return standings;
3713
4228
  }
3714
4229
  /**
3715
4230
  * Owner-signed WITHDRAWAL of a cadre invitation: file the `Revocation` tombstone retiring its
@@ -3729,59 +4244,66 @@ export class ControlDatabase {
3729
4244
  * node was alone is queued for re-issue on cohort growth ({@link CadreNode.noteGuardedDelete}).
3730
4245
  */
3731
4246
  async withdrawCadreInvite(key, ownerKey, signMessage) {
4247
+ return this.withdrawIssuedRow('CadreInvite', key, ownerKey, signMessage);
4248
+ }
4249
+ /**
4250
+ * The body of {@link withdrawCadreInvite} and {@link withdrawOwnerGrant}: under the write
4251
+ * lock, file the owner-signed tombstone retiring the row's stamp with the row kept, and fire
4252
+ * the guarded-delete listener for it (so a withdrawal made while alone is re-issued).
4253
+ */
4254
+ async withdrawIssuedRow(table, key, ownerKey, signMessage) {
3732
4255
  this.ensureInitialized();
3733
4256
  const filed = await this.lockedWithRetry(async () => {
3734
- const [withdrawal] = await this.signCadreInviteWithdrawals([key], signMessage);
4257
+ const [withdrawal] = await this.signWithdrawals(table, [key], signMessage);
3735
4258
  if (withdrawal === undefined) {
3736
4259
  return null;
3737
4260
  }
3738
4261
  await this.execTombstone(withdrawal.ref, withdrawal.signature, ownerKey);
3739
4262
  return withdrawal.ref;
3740
- }, {}, 'cadre-invite-withdraw');
4263
+ }, {}, `${ISSUED_TABLE_REASON[table]}-withdraw`);
3741
4264
  if (filed === null) {
3742
4265
  return false;
3743
4266
  }
3744
- log('Cadre invitation withdrawn: %s (stamp %s retired, row kept)', key, filed.stampId);
4267
+ log('%s withdrawn: %s (stamp %s retired, row kept)', table, key, filed.stampId);
3745
4268
  this.notifyGuardedDelete(filed);
3746
4269
  return true;
3747
4270
  }
3748
4271
  /**
3749
- * The withdrawal tombstones for the `CadreInvite` rows among `keys` that are held here and
3750
- * not yet withdrawn, signed and ready for {@link execTombstone}: the body
3751
- * {@link withdrawCadreInvite} files one of and {@link removeMachine} files several of in one
4272
+ * The withdrawal tombstones for the `table` rows among `keys` that are held here and not
4273
+ * yet withdrawn, signed and ready for {@link execTombstone}: the body
4274
+ * {@link withdrawIssuedRow} files one of and {@link removeMachine} files several of in one
3752
4275
  * transaction. Inside the locked body (every read `retry: false`); an absent or already
3753
4276
  * withdrawn key is logged and left out.
3754
4277
  */
3755
- async signCadreInviteWithdrawals(keys, signMessage) {
4278
+ async signWithdrawals(table, keys, signMessage) {
3756
4279
  if (keys.length === 0) {
3757
4280
  return [];
3758
4281
  }
3759
- const withdrawn = await this.queryRevokedStamps('CadreInvite', false);
4282
+ const withdrawn = await this.queryRevokedStamps(table, false);
3760
4283
  const withdrawals = [];
3761
4284
  for (const key of keys) {
3762
- const stampId = await this.queryStampId('CadreInvite', key, false);
4285
+ const stampId = await this.queryStampId(table, key, false);
3763
4286
  if (stampId === null) {
3764
- log('withdrawCadreInvite: no CadreInvite row for %s (nothing to withdraw)', key);
4287
+ log('withdraw %s: no row for %s (nothing to withdraw)', table, key);
3765
4288
  continue;
3766
4289
  }
3767
4290
  if (withdrawn.has(stampId)) {
3768
- log('withdrawCadreInvite: %s already withdrawn (stamp %s retired)', key, stampId);
4291
+ log('withdraw %s: %s already withdrawn (stamp %s retired)', table, key, stampId);
3769
4292
  continue;
3770
4293
  }
3771
- const ref = { tableName: 'CadreInvite', rowKey: key, stampId };
3772
- withdrawals.push({ ref, signature: signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
4294
+ const ref = { tableName: table, rowKey: key, stampId };
4295
+ withdrawals.push({ ref, signature: await signMessage(revocationTombstoneMessage(this.config.partyId, ref)) });
3773
4296
  }
3774
4297
  return withdrawals;
3775
4298
  }
3776
4299
  /**
3777
- * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member and,
3778
- * when the invitation grants ownership, as an owner — with NO owner signature. One
3779
- * transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`), the
3780
- * `OwnerKey` row when granted (consent branch of `Authorized`), and last the
3781
- * `CadreInviteUsage` row that authorizes both; the usage row's own checks (`PeerExists`,
3782
- * `OwnerExists`) read the rows back, so the three are mutually bound to this exact
3783
- * redemption and none can be held in reserve. The invitation must already be committed
3784
- * ({@link seatCadreInvite}): every consent branch reads `committed.CadreInvite`.
4300
+ * Redeem a seated `CadreInvite` on the owner's behalf: admit `peerId` as a cadre member —
4301
+ * with NO owner signature, and as a machine only (ownership is {@link redeemOwnerGrant}'s).
4302
+ * One transaction writes the `CadrePeer` row (consent branch of `AuthorizedInsert`) and then
4303
+ * the `CadreInviteUsage` row that authorizes it; the usage row's own check (`PeerExists`)
4304
+ * reads the peer row back, so the two are mutually bound to this exact redemption and
4305
+ * neither can be held in reserve. The invitation must already be committed
4306
+ * ({@link seatCadreInvite}): the consent branch reads `committed.CadreInvite`.
3785
4307
  *
3786
4308
  * The two signatures are the holder's and the device's, minted on the device over
3787
4309
  * {@link cadreInviteRedeemMessage} / {@link cadreInviteConsentMessage}; this machine only
@@ -3790,79 +4312,59 @@ export class ControlDatabase {
3790
4312
  * (`verifyInvitationAdmission`) — a mismatched pair is written and then never trusted.
3791
4313
  *
3792
4314
  * Idempotent for a retry after a dropped reply: when the device's row is already present
3793
- * (and its `OwnerKey` row too, when granted) nothing is written and no seat is spent —
3794
- * `alreadyMember: true`. A present row whose `OwnerKey` row is missing under an
3795
- * owner-granting invitation is completed: the usage row then names the existing peer stamp.
3796
- * The seat check ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a
3797
- * single-use redemption is answered `alreadyMember` rather than refused as exhausted.
4315
+ * nothing is written and no seat is spent — `alreadyMember: true`. The seat check
4316
+ * ({@link assertCadreInviteSeatRemains}) runs after that, so the retry of a single-use
4317
+ * redemption is answered `alreadyMember` rather than refused as exhausted.
3798
4318
  *
3799
4319
  * Through {@link mutateCadrePeer}, so the membership hub is notified; inside the locked body
3800
4320
  * every read passes `retry: false` and every stamp is minted per attempt, so a
3801
4321
  * {@link lockedWithRetry} re-run starts clean.
3802
4322
  *
3803
4323
  * @throws {InvitationExhaustedError} when the invitation's seats are all spent (by name,
3804
- * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains}).
4324
+ * ahead of the schema's generic `Authorized` refusal — see {@link assertSeatRemains});
4325
+ * {@link IssuedRowNotHereYetError} when no `CadreInvite` row is readable here.
3805
4326
  */
3806
4327
  async redeemCadreInvite(params) {
3807
4328
  this.ensureInitialized();
3808
- const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
4329
+ const { inviteKey, peerId, peerKey, record, usageStampId, inviteSig, peerSig } = params;
3809
4330
  const nowMs = params.nowMs ?? Date.now();
3810
4331
  const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
3811
4332
  log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
3812
4333
  return this.mutateCadrePeer('cadre-invite-redeem', async () => {
3813
- const invite = await this.queryCadreInvite(inviteKey, false);
4334
+ const invite = params.held ?? await this.queryCadreInvite(inviteKey, false);
3814
4335
  if (invite === null) {
3815
- throw new Error(`Cannot redeem cadre invitation ${inviteKey}: no CadreInvite row is seated on this node (seatCadreInvite first)`);
4336
+ throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
3816
4337
  }
3817
- const seated = await this.readSeatedAdmission(peerId, peerKey, invite.grantsOwner);
3818
- if (seated.peerStampId !== null && (!invite.grantsOwner || seated.ownerStampId !== null)) {
4338
+ // A raw stamp read (queryStampId), so a physically present row counts; inside the locked
4339
+ // body, so never retried.
4340
+ const seatedPeerStampId = await this.queryStampId('CadrePeer', peerId, false);
4341
+ if (seatedPeerStampId !== null) {
3819
4342
  log('redeemCadreInvite: %s already admitted; nothing written', peerId);
3820
- return { alreadyMember: true, peerStampId: seated.peerStampId, ownerStampId: seated.ownerStampId };
4343
+ return { alreadyMember: true, peerStampId: seatedPeerStampId };
3821
4344
  }
3822
4345
  await this.assertCadreInviteSeatRemains(invite);
3823
- // Stamps minted from the ADMITTED device's id, as insertCadrePeer does: the nonce names
3824
- // the row it seats. Minted inside the body, so a re-run mints afresh over a rolled-back
3825
- // attempt rather than re-presenting a stamp the usage row may already name.
3826
- const peerStampId = seated.peerStampId ?? generateStampId(peerId);
3827
- const ownerStampId = invite.grantsOwner ? (seated.ownerStampId ?? generateStampId(peerId)) : null;
4346
+ // The stamp is minted from the ADMITTED device's id, as insertCadrePeer does: the nonce
4347
+ // names the row it seats. Minted inside the body, so a re-run mints afresh over a
4348
+ // rolled-back attempt rather than re-presenting a stamp the usage row may already name.
4349
+ const peerStampId = generateStampId(peerId);
3828
4350
  const nowCanonical = await canonicalDatetime(this.db, nowMs);
3829
4351
  await this.inTransaction('cadre-invite-redeem', async () => {
3830
- if (seated.peerStampId === null) {
3831
- await this.db.exec(`
3832
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
3833
- with context OwnerKey = null, Signature = null
3834
- values (?, ?, ?, ?, null, ?, ?, null, ?)
3835
- `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
3836
- }
3837
- if (ownerStampId !== null && seated.ownerStampId === null) {
3838
- await this.db.exec(`
3839
- insert into CadreControl.OwnerKey (Key, StampId, VouchOwner, VouchSig, VouchUsage)
3840
- with context OwnerKey = null, Signature = null
3841
- values (?, ?, ?, null, ?)
3842
- `, [peerKey, ownerStampId, invite.issuerKey, usageStampId]);
3843
- }
3844
4352
  await this.db.exec(`
3845
- insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, OwnerStampId, InviteSig, PeerSig)
4353
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig, VouchUsage)
4354
+ with context OwnerKey = null, Signature = null
4355
+ values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)
4356
+ `, [peerId, peerKey, multiaddr, record?.updatedAt ?? nowMs, record?.sig ?? null, record?.profile ?? null,
4357
+ peerStampId, invite.issuerKey, usageStampId]);
4358
+ await this.db.exec(`
4359
+ insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
3846
4360
  with context Now = ?
3847
- values (?, ?, ?, ?, ?, ?, ?, ?)
3848
- `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, ownerStampId, inviteSig, peerSig]);
4361
+ values (?, ?, ?, ?, ?, ?, ?)
4362
+ `, [nowCanonical, usageStampId, inviteKey, peerId, peerKey, peerStampId, inviteSig, peerSig]);
3849
4363
  });
3850
- log('Redeemed cadre invitation %s: %s admitted (owner=%s, usage %s)', inviteKey, peerId, ownerStampId !== null, usageStampId);
3851
- return { alreadyMember: false, peerStampId, ownerStampId };
4364
+ log('Redeemed cadre invitation %s: %s admitted (usage %s)', inviteKey, peerId, usageStampId);
4365
+ return { alreadyMember: false, peerStampId };
3852
4366
  });
3853
4367
  }
3854
- /**
3855
- * The rows a redemption would seat that are already present: the device's `CadrePeer`
3856
- * stamp, and — only when the invitation grants ownership — its `OwnerKey` stamp. Raw stamp
3857
- * reads ({@link queryStampId}), so a physically present row counts; inside the locked
3858
- * body, so never retried.
3859
- */
3860
- async readSeatedAdmission(peerId, peerKey, grantsOwner) {
3861
- return {
3862
- peerStampId: await this.queryStampId('CadrePeer', peerId, false),
3863
- ownerStampId: grantsOwner ? await this.queryStampId('OwnerKey', peerKey, false) : null,
3864
- };
3865
- }
3866
4368
  /**
3867
4369
  * Refuse a redemption that would consume a seat the cadre invitation does not have, by
3868
4370
  * name ({@link InvitationExhaustedError}) — the cadre-invitation twin of
@@ -3870,19 +4372,232 @@ export class ControlDatabase {
3870
4372
  * generic `Authorized` failure a caller would otherwise report as retryable. Reads the
3871
4373
  * committed count inside the locked body, so on a same-node race the loser is refused here.
3872
4374
  */
3873
- async assertCadreInviteSeatRemains(invite) {
3874
- if (invite.totalUses === null) {
4375
+ assertCadreInviteSeatRemains(invite) {
4376
+ return this.assertIssuedSeatRemains(invite, key => this.countCadreInviteUsage(key, false));
4377
+ }
4378
+ /** The body of {@link assertCadreInviteSeatRemains} and {@link assertOwnerGrantSeatRemains}; `countUsage` reads inside the locked body. */
4379
+ async assertIssuedSeatRemains(row, countUsage) {
4380
+ if (row.totalUses === null) {
3875
4381
  return;
3876
4382
  }
3877
- const used = await this.countCadreInviteUsage(invite.key, false);
3878
- if (used >= invite.totalUses) {
3879
- throw new InvitationExhaustedError(invite.key, used, invite.totalUses);
4383
+ const used = await countUsage(row.key);
4384
+ if (used >= row.totalUses) {
4385
+ throw new InvitationExhaustedError(row.key, used, row.totalUses);
3880
4386
  }
3881
4387
  }
4388
+ // ── Owner grants (OwnerGrant / OwnerGrantUsage) ─────────────────────────────────────────
4389
+ /**
4390
+ * Mint and insert an owner-signed `OwnerGrant`: a grant of ownership to whoever holds the
4391
+ * grant's private half, redeemable through any member machine ({@link redeemOwnerGrant})
4392
+ * to seat an owner key the holder generates or already holds. No machine is admitted and no
4393
+ * peer id is named. The grant twin of {@link insertCadreInvite}: the owner signs the whole
4394
+ * row ({@link ownerGrantAddMessage}), `ExpiresAt` through {@link canonicalDatetime}, and
4395
+ * the returned row is what the grant bundle carries, re-inserted verbatim by
4396
+ * {@link seatOwnerGrant} on a member that has not received it by replication.
4397
+ *
4398
+ * @param grant.key - the grant's ed25519 PUBLIC key (base64url), the row's primary key; the
4399
+ * caller minted the pair and hands the private half to the holder out of band
4400
+ * @param grant.label - the label every key this grant seats will carry
4401
+ * ({@link ownerKeyLabel}'s rule: trimmed, at most 64 characters, blank meaning none); the
4402
+ * issuer's choice, which the holder cannot change
4403
+ * @param grant.expiresAtMs - epoch ms after which it cannot be redeemed; omit or null for
4404
+ * never. The short default lifetime is the node API's to apply, not this writer's.
4405
+ * @param grant.totalUses - how many redemptions it allows; omit for unlimited
4406
+ * @returns the row as stored, `expiresAt` in its canonical form
4407
+ */
4408
+ async insertOwnerGrant(grant, ownerKey, signMessage) {
4409
+ this.ensureInitialized();
4410
+ const key = requireEd25519PublicKeyB64(grant.key, 'owner grant key');
4411
+ const signed = {
4412
+ key,
4413
+ label: ownerKeyLabel(grant.label),
4414
+ expiresAt: grant.expiresAtMs == null ? null : await canonicalDatetime(this.db, grant.expiresAtMs),
4415
+ totalUses: grant.totalUses ?? null,
4416
+ stampId: generateStampId(this.config.libp2pNode.peerId.toString()),
4417
+ };
4418
+ const row = {
4419
+ ...signed,
4420
+ issuerKey: ownerKey,
4421
+ issuerSig: await signMessage(ownerGrantAddMessage(this.config.partyId, signed)),
4422
+ };
4423
+ await this.lockedWithRetry(() => this.execOwnerGrantInsert(row), {}, 'owner-grant-insert');
4424
+ log('Owner grant inserted: %s (label=%s)', key, row.label ?? 'none');
4425
+ return row;
4426
+ }
4427
+ /**
4428
+ * Seat an owner-signed `OwnerGrant` row received inside a grant bundle, under its own stored
4429
+ * `IssuerKey`/`IssuerSig` as the write context — insert-if-absent, as {@link seatCadreInvite}.
4430
+ *
4431
+ * @returns `true` when this call inserted the row, `false` when it was already present.
4432
+ * @throws {OwnerGrantIssuerUnknownError} when `OwnerGrant.AuthorizedInsert` refuses it: the
4433
+ * issuer is not an owner on this node (not replicated yet, or removed), or the signature does
4434
+ * not verify. {@link IssuedRowNotHereYetError} when the cohort holds the row and this node
4435
+ * cannot read it yet. Every other refusal (a withdrawn stamp, `NotRevoked`) propagates as is.
4436
+ */
4437
+ async seatOwnerGrant(row) {
4438
+ return this.seatIssuedRow('OwnerGrant', row.key, () => this.execOwnerGrantInsert(row), cause => new OwnerGrantIssuerUnknownError(row.key, row.issuerKey, { cause }));
4439
+ }
4440
+ /** Bare `OwnerGrant` insert shared by {@link insertOwnerGrant} and {@link seatOwnerGrant}; the caller holds the lock. */
4441
+ async execOwnerGrantInsert(row) {
4442
+ await this.db.exec(`
4443
+ insert into CadreControl.OwnerGrant (Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId)
4444
+ with context OwnerKey = ?, Signature = ?
4445
+ values (?, ?, ?, ?, ?, ?, ?)
4446
+ `, [
4447
+ row.issuerKey, row.issuerSig,
4448
+ row.key, row.label, row.expiresAt, row.totalUses,
4449
+ row.issuerKey, row.issuerSig, row.stampId,
4450
+ ]);
4451
+ }
4452
+ /**
4453
+ * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
4454
+ * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
4455
+ * away. Read once per redemption and carried into {@link isOwnerGrantLive} and
4456
+ * {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
4457
+ * handed none and reads inside its locked body.
4458
+ */
4459
+ async queryOwnerGrant(key, retry = true) {
4460
+ this.ensureInitialized();
4461
+ for (const row of await this.readRows(`${OWNER_GRANT_SELECT_SQL} where Key = ?`, [key], 'owner-grant', retry)) {
4462
+ return ownerGrantRowOf(row);
4463
+ }
4464
+ return null;
4465
+ }
4466
+ /**
4467
+ * Every `OwnerGrant` row this node holds, withdrawn and expired ones included. Read by the
4468
+ * anchor sync when at least one `OwnerKey` row is consent-seated (`CadreNode.loadOwnerGrantChain`);
4469
+ * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
4470
+ */
4471
+ async queryOwnerGrants(retry = true) {
4472
+ return (await this.readMembershipRows(this.ownerGrantView, retry)).map(ownerGrantRowOf);
4473
+ }
4474
+ /** 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}). */
4475
+ async queryOwnerGrantUsages(retry = true) {
4476
+ return (await this.readMembershipRows(this.ownerGrantUsageView, retry)).map(ownerGrantUsageRowOf);
4477
+ }
4478
+ /**
4479
+ * How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
4480
+ * through the `OwnerGrantUsageByGrant` index. A permissive PRE-check like
4481
+ * {@link countCadreInviteUsage}: the cap is `OwnerGrantUsage.Authorized`'s own committed count.
4482
+ */
4483
+ async countOwnerGrantUsage(key, retry = true) {
4484
+ return this.countUsageRows('select count(1) as Count from CadreControl.OwnerGrantUsage where GrantKey = ?', key, 'owner-grant-usage-count', retry);
4485
+ }
4486
+ /**
4487
+ * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
4488
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
4489
+ * this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
4490
+ * `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
4491
+ * permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
4492
+ * condition. Its use count is read again inside the write lock, for
4493
+ * {@link isCadreInviteLive}'s reason.
4494
+ */
4495
+ async isOwnerGrantLive(grant, held, nowMs = Date.now()) {
4496
+ this.ensureInitialized();
4497
+ const row = held ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
4498
+ return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
4499
+ }
4500
+ /**
4501
+ * Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
4502
+ * and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
4503
+ */
4504
+ async listOwnerGrantStatuses(nowMs = Date.now()) {
4505
+ const standings = await this.issuedRowStandings('OwnerGrant', await this.queryOwnerGrants(), nowMs, key => this.countOwnerGrantUsage(key));
4506
+ return standings.map(({ row, ...standing }) => ({ grant: row, ...standing }));
4507
+ }
4508
+ /**
4509
+ * Owner-signed WITHDRAWAL of an owner grant: file the `Revocation` tombstone retiring its
4510
+ * stamp, with the row kept, exactly as {@link withdrawCadreInvite} withdraws an invitation
4511
+ * and for the same reason (every key the grant seated proves itself through the row;
4512
+ * `OwnerGrant.NoDelete`, `Revocation.RowIsGone`). Afterwards `OwnerGrantUsage.Authorized`
4513
+ * refuses every further redemption and `OwnerGrant.NotRevoked` refuses re-seating the
4514
+ * holder's copy on any node holding the tombstone. Keys already seated stay owners;
4515
+ * removing one is {@link deleteOwnerKey}.
4516
+ *
4517
+ * Idempotent: `false` (nothing written) when the row is absent here or already withdrawn,
4518
+ * `true` when this call filed the tombstone. Fires the guarded-delete listener, so a
4519
+ * withdrawal committed while alone is re-issued on cohort growth.
4520
+ */
4521
+ async withdrawOwnerGrant(key, ownerKey, signMessage) {
4522
+ return this.withdrawIssuedRow('OwnerGrant', key, ownerKey, signMessage);
4523
+ }
4524
+ /**
4525
+ * Redeem a seated `OwnerGrant` on the issuer's behalf: seat `seatedKey` as an owner with NO
4526
+ * owner signature. One transaction writes the `OwnerKey` row (the grant branch of
4527
+ * `Authorized`: `VouchOwner` the issuer, `VouchSig` null, `VouchUsage` the usage stamp,
4528
+ * `Label` the grant's) and then the `OwnerGrantUsage` row that authorizes it; the usage
4529
+ * row's `SeatedExists` reads the owner row back, so the two are bound to this exact
4530
+ * redemption and neither can be held in reserve. The grant must already be committed
4531
+ * ({@link seatOwnerGrant}): both consent reads are of `committed.OwnerGrant`. No machine is
4532
+ * admitted and no peer id is named.
4533
+ *
4534
+ * The two signatures are minted where the private keys are (the grant's and the seated
4535
+ * key's), over {@link ownerGrantRedeemMessage} / {@link ownerGrantConsentMessage}; this
4536
+ * machine only verifies them, by writing the row.
4537
+ *
4538
+ * Idempotent, and a no-op for a key that is already an owner: when `seatedKey` has a live
4539
+ * `OwnerKey` row here nothing is written and no use is spent — `alreadyOwner: true` with
4540
+ * that row's stamp. The seat check ({@link assertOwnerGrantSeatRemains}) runs after that, so
4541
+ * the retry of a single-use redemption is answered `alreadyOwner` rather than refused as
4542
+ * exhausted. A key removed earlier is seated afresh under a new stamp (`NotRevoked` passes),
4543
+ * except on a machine that still holds its retired row, which refuses by name
4544
+ * ({@link liveOwnerKeyStamp}).
4545
+ *
4546
+ * Through {@link mutateCadrePeer}, so the membership hub is notified and the anchors follow;
4547
+ * inside the locked body every read passes `retry: false` and the stamp is minted per
4548
+ * attempt, so a {@link lockedWithRetry} re-run starts clean.
4549
+ *
4550
+ * @throws {InvitationExhaustedError} when the grant's uses are all spent (by name, ahead of
4551
+ * the schema's generic `Authorized` refusal); its `token` is the grant key.
4552
+ * {@link IssuedRowNotHereYetError} when no `OwnerGrant` row is readable here.
4553
+ */
4554
+ async redeemOwnerGrant(params) {
4555
+ this.ensureInitialized();
4556
+ const { grantKey, seatedKey, usageStampId, grantSig, ownerSig } = params;
4557
+ const nowMs = params.nowMs ?? Date.now();
4558
+ log('Redeeming owner grant %s for %s', grantKey, seatedKey);
4559
+ return this.mutateCadrePeer('owner-grant-redeem', async () => {
4560
+ const grant = params.held ?? await this.queryOwnerGrant(grantKey, false);
4561
+ if (grant === null) {
4562
+ throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
4563
+ }
4564
+ const heldStamp = await this.liveOwnerKeyStamp(seatedKey);
4565
+ if (heldStamp !== null) {
4566
+ log('redeemOwnerGrant: %s is already an owner; nothing written', seatedKey);
4567
+ return { alreadyOwner: true, seatedStampId: heldStamp };
4568
+ }
4569
+ await this.assertOwnerGrantSeatRemains(grant);
4570
+ // Minted inside the body, so a re-run mints afresh over a rolled-back attempt rather
4571
+ // than re-presenting a stamp the usage row may already name.
4572
+ const seatedStampId = generateStampId(this.config.libp2pNode.peerId.toString());
4573
+ const nowCanonical = await canonicalDatetime(this.db, nowMs);
4574
+ await this.inTransaction('owner-grant-redeem', async () => {
4575
+ await this.db.exec(`
4576
+ insert into CadreControl.OwnerKey (Key, Label, StampId, VouchOwner, VouchSig, VouchUsage)
4577
+ with context OwnerKey = null, Signature = null
4578
+ values (?, ?, ?, ?, null, ?)
4579
+ `, [seatedKey, grant.label, seatedStampId, grant.issuerKey, usageStampId]);
4580
+ await this.db.exec(`
4581
+ insert into CadreControl.OwnerGrantUsage (UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig)
4582
+ with context Now = ?
4583
+ values (?, ?, ?, ?, ?, ?)
4584
+ `, [nowCanonical, usageStampId, grantKey, seatedKey, seatedStampId, grantSig, ownerSig]);
4585
+ });
4586
+ log('Redeemed owner grant %s: %s seated as an owner (usage %s)', grantKey, seatedKey, usageStampId);
4587
+ return { alreadyOwner: false, seatedStampId };
4588
+ });
4589
+ }
4590
+ /** {@link assertCadreInviteSeatRemains} for an owner grant: refuse an exhausted grant by name, ahead of the write. */
4591
+ assertOwnerGrantSeatRemains(grant) {
4592
+ return this.assertIssuedSeatRemains(grant, key => this.countOwnerGrantUsage(key, false));
4593
+ }
3882
4594
  /**
3883
4595
  * Close the database and cleanup resources.
3884
4596
  *
3885
- * Drains the local-write chain first: {@link withWriteLock} can park a write behind
4597
+ * Disposes every {@link watchTables} subscription before anything else, so a commit that
4598
+ * lands while the write chain drains schedules no run, and again before dropping the handle.
4599
+ *
4600
+ * Then drains the local-write chain: {@link withWriteLock} can park a write behind
3886
4601
  * others across an await, and a queued closure evaluates `this.db!` only when it
3887
4602
  * finally runs — so nulling the handle out from under it would throw a TypeError on a
3888
4603
  * null handle instead of committing. The tail never rejects (it swallows both
@@ -3893,11 +4608,17 @@ export class ControlDatabase {
3893
4608
  * deadline) if a write can ever hang.
3894
4609
  */
3895
4610
  async close() {
4611
+ this.tableWatches.disposeAll();
3896
4612
  await this.writeQueue;
3897
4613
  if (this.collectionFactory) {
3898
4614
  await this.collectionFactory.shutdown();
3899
4615
  this.collectionFactory = null;
3900
4616
  }
4617
+ this.txnBridge = null;
4618
+ // Again: a subscription taken during the awaits above would keep its timers past close.
4619
+ this.tableWatches.disposeAll();
4620
+ // Here, not first: a read started during the awaits above must not install either.
4621
+ this.invalidateMembershipViews();
3901
4622
  if (this.db) {
3902
4623
  void this.db.close();
3903
4624
  this.db = null;