@serfab/cadre-core 1.17.0 → 1.19.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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1182 -566
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +17 -14
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -7,8 +7,10 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
7
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
8
  import type { Libp2p } from '@libp2p/interface';
9
9
  import type { IRepo } from '@optimystic/db-core';
10
- import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
11
- import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, controlSchemaFileDeclaration } from './control-schema.js';
10
+ import { createEmptyCollections } from '@serfab/quereus-plugin-sereus';
11
+ import type { CollectionRegistrySource } from '@serfab/quereus-plugin-sereus';
12
+ import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
13
+ import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, CONTROL_SCHEMA_NAME, controlSchemaFileDeclaration } from './control-schema.js';
12
14
  import { refuseIncompatibleControlStore } from './control-schema-version.js';
13
15
  import { canonicalDatetime } from './canonical-datetime.js';
14
16
  import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
@@ -16,12 +18,16 @@ import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDo
16
18
  import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
17
19
  import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
18
20
  import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
19
- import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
21
+ import { retryControlWrite, LOST_UPDATE_RETRY_POLICY, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
20
22
  import type { ControlWriteRetryOptions } from './control-write-retry.js';
21
23
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
22
24
  import type { ControlReadRetryOptions } from './control-read-retry.js';
23
25
  import { chainMessages } from './control-retry.js';
24
26
  import type { ControlRetryAbandonment } from './control-retry.js';
27
+ import { TableWatchRegistry } from './table-change-watch.js';
28
+ import type { TableChangeWatch, TableChangeWatchOptions } from './table-change-watch.js';
29
+ import { MembershipView } from './membership-view.js';
30
+ import type { FormationInviteFacts } from './formation-token.js';
25
31
 
26
32
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
27
33
 
@@ -58,12 +64,11 @@ export function generateStampId(peerId: string): string {
58
64
  * present locally.
59
65
  *
60
66
  * Consent is bound to the strand's one-off `StampId` (see the consent branch of
61
- * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
62
- * The ordinary "host strand has not converged on this responder yet" case is caught
63
- * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
64
- * reaching here means the row vanished between that check and the write, which is a
65
- * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
66
- * CHECK at commit (which would drop the write with a far less legible error).
67
+ * `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
68
+ * inserting, and throws this when the row is absent rather than leaving it to fail the
69
+ * deferred `StrandExists` CHECK at commit (a far less legible error). The formation
70
+ * responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
71
+ * reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
67
72
  */
68
73
  export class MissingHostStrandError extends Error {
69
74
  constructor(readonly strandId: string, readonly token: string) {
@@ -106,16 +111,16 @@ export class FormationAbortedError extends Error {
106
111
  * the write, inside {@link ControlDatabase.redeemInvitation} /
107
112
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
108
113
  * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
109
- * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
110
- * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
111
- * pass — and off nothing else. It exists because
114
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
115
+ * `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
116
+ * manager issues a membership pass — and off nothing else. It exists because
112
117
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
113
118
  * schema's own count-based cap clause, which the manager reports as a retryable
114
119
  * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
115
120
  * Two redemptions of one token racing on the SAME node serialize behind the local write
116
- * queue, so the loser reads the winner's committed row and is refused here by name —
117
- * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
118
- * ran before either redemption wrote anything.
121
+ * queue, so the loser reads the winner's committed row and is refused here by name — the
122
+ * use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
123
+ * read before either redemption wrote anything.
119
124
  */
120
125
  export class InvitationExhaustedError extends Error {
121
126
  constructor(
@@ -484,6 +489,8 @@ interface OptimysticPluginResult {
484
489
  * compose-strand.ts).
485
490
  */
486
491
  hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
492
+ /** The transaction bridge, whose registry holds every collection a control table opened; see {@link ControlDatabase.ensureOwnerKey}. */
493
+ txnBridge: CollectionRegistrySource;
487
494
  [key: string]: unknown;
488
495
  }
489
496
 
@@ -542,10 +549,9 @@ interface GuardedRemoval {
542
549
 
543
550
  /**
544
551
  * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
545
- * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
546
- * tombstone writer (a guarded removal, an issued row's withdrawal, the ledger marker) so the
547
- * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
548
- * `revocationDigest`.
552
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by both
553
+ * tombstone writers (a guarded removal, an issued row's withdrawal) so the field order lives
554
+ * in one place; the base64url verifier is `peer-authorization.ts`'s `revocationDigest`.
549
555
  */
550
556
  function revocationTombstoneMessage(partyId: string, ref: { tableName: string; rowKey: string; stampId: string }): Uint8Array {
551
557
  return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
@@ -587,6 +593,63 @@ export function ownerKeyLabel(label: string | null | undefined): string | null {
587
593
  */
588
594
  export type ControlSign = (message: Uint8Array) => string | Promise<string>;
589
595
 
596
+ /**
597
+ * Signs this node's own address record over its `CadrePeer` row as it stands —
598
+ * {@link ControlDatabase.updateSelfPeerRecord}'s callback, run inside the locked write body on
599
+ * every attempt, so the record written is always stamped past the row that was actually read.
600
+ */
601
+ export type SelfRecordSigner = (current: PeerAddressRecord) => PeerAddressRecord;
602
+
603
+ /**
604
+ * Which owner re-touch {@link ControlDatabase.reauthorizeCadrePeer} makes over a `CadrePeer` row:
605
+ *
606
+ * - `'rebroadcast'` — the write-while-alone drain re-emitting a row that committed local-only. It
607
+ * bumps `UpdatedAt` so the row goes out as a fresh transaction, and writes only while the row's
608
+ * `Sig` is null: once the machine has self-published, the row is its to refresh, and a bump
609
+ * without a re-sign would leave its signature unverifiable.
610
+ * - `'revouch'` — an owner rebinding the row's voucher to its own key before the vouching key is
611
+ * removed (`CadreNode.removeOwner`). Written whether or not the machine has self-published, and
612
+ * leaves `UpdatedAt` untouched, so a self-signed record stays verifiable.
613
+ */
614
+ export type CadrePeerRetouchMode = 'rebroadcast' | 'revouch';
615
+
616
+ /**
617
+ * What {@link ControlDatabase.reauthorizeCadrePeer} found and did: the row was re-touched; no
618
+ * readable row exists (absent, or its stamp retired); or — `'rebroadcast'` only — the machine has
619
+ * self-published since, so the row is no longer the owner's to re-emit. The two no-write outcomes
620
+ * are decided over the row as read inside the locked body, so a caller can act on them.
621
+ */
622
+ export type CadrePeerRetouchOutcome = 'retouched' | 'absent' | 'self-published';
623
+
624
+ /** A formation invitation written with a founding ({@link FoundingRows}), bound to the strand founded. */
625
+ export interface FoundingInvite {
626
+ /** The invitation token, the `FormationInvite` primary key. */
627
+ token: string;
628
+ /** The sApp a redeemed strand will use. */
629
+ sAppId: string;
630
+ /** Epoch ms after which it cannot be redeemed; omit for never. */
631
+ expiresAtMs?: number;
632
+ /** How many redemptions it allows; omit for unlimited. */
633
+ totalUses?: number;
634
+ /** Web hook sent the disclosure; a `ValidationKey` then signs off each redemption. */
635
+ validationUrl?: string;
636
+ }
637
+
638
+ /** The control rows one founding writes together; see {@link ControlDatabase.insertFoundingRows}. */
639
+ export interface FoundingRows {
640
+ strand: { id: string; type: 'o' | 'c'; memberPrivateKey?: string };
641
+ /** This party's membership identity key for a closed strand; absent for an open strand, or when a stored key is adopted. */
642
+ partyKey?: string;
643
+ /** A formation invitation bound to this strand (its `StrandId` is `strand.id`). */
644
+ formationInvite?: FoundingInvite;
645
+ }
646
+
647
+ /** One owner-signed insert, stamped and signed: the statement and parameters to run under the write lock. */
648
+ interface SignedInsert {
649
+ sql: string;
650
+ params: SqlParameters;
651
+ }
652
+
590
653
  /** What an owner asks {@link ControlDatabase.insertCadreInvite} (or {@link ControlDatabase.signCadreInvite}) to sign. */
591
654
  export interface CadreInviteFields {
592
655
  /** The invitation public key (base64url ed25519), the row's primary key. */
@@ -615,7 +678,7 @@ async function signGuardedRemoval(
615
678
  }
616
679
 
617
680
  /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
618
- type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
681
+ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'StrandPartyKey' | 'JoinedStrand'>;
619
682
 
620
683
  /** The control tables whose repeat insert of a held key a caller may classify. */
621
684
  type KeyConflictTable = StrandIdTable | IssuedTable | Extract<RevocableTable, 'JoinRequest'>;
@@ -642,7 +705,8 @@ function keyConflictPattern(table: KeyConflictTable): RegExp {
642
705
  * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
643
706
  * landed" (re-read, and no-op when the row matches) apart from every other rejection —
644
707
  * an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
645
- * the same test tells "another machine of this party already published this join" apart.
708
+ * the same test tells "another machine of this party already published this join" apart, and
709
+ * for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
646
710
  *
647
711
  * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
648
712
  * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
@@ -709,31 +773,17 @@ export class PendingJoinChangedError extends Error {
709
773
  }
710
774
 
711
775
  /**
712
- * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
713
- * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
714
- * block. The schema's table comment says why that matters and why the row can never read
715
- * as a retirement; `RowIsGone` pins this exact triple.
776
+ * A `CadreControl.Revocation` row that retires nothing, which parties founded before
777
+ * `control-collections-created-at-cadre-founding` may hold: an owner filed it so the table
778
+ * had a collection, which genesis now creates. Nothing files it now. `RowIsGone` still admits
779
+ * this exact triple, and {@link ControlDatabase.queryRevocations} skips it.
716
780
  */
717
- export const REVOCATION_LEDGER_MARKER = {
781
+ const REVOCATION_LEDGER_MARKER = {
718
782
  tableName: 'Revocation',
719
783
  rowKey: 'ledger',
720
784
  stampId: 'opened',
721
785
  } as const;
722
786
 
723
- /**
724
- * The rejection a second insert of the ledger marker produces: the optimystic vtab's
725
- * primary-key wording (see {@link keyConflictPattern}), naming both columns of
726
- * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
727
- * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
728
- * has no other unique constraint.
729
- */
730
- const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
731
-
732
- /** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
733
- function isRevocationLedgerConflict(error: unknown): boolean {
734
- return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
735
- }
736
-
737
787
  /** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
738
788
  function errorChainMatches(error: unknown, pattern: RegExp): boolean {
739
789
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
@@ -807,7 +857,91 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
807
857
  const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(REAPABLE_TABLES);
808
858
  const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
809
859
 
810
- /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
860
+ /** One control read's rows, and whether it took the committed-read path ({@link ControlDatabase.readRowsOnce}). */
861
+ interface RoutedRead {
862
+ rows: Record<string, SqlValue>[];
863
+ committed: boolean;
864
+ }
865
+
866
+ /**
867
+ * The full-table scans the `Revocation`, `CadrePeer` and `OwnerKey` membership views load
868
+ * ({@link ControlDatabase.readMembershipRows}); the four chain tables' scans sit beside their
869
+ * row mappers below. Specs rig these reads by their text: `from CadreControl.Revocation`, and
870
+ * the `CadrePeer` scan by its `select PeerId, Multiaddr, StampId` prefix, which no other
871
+ * `CadrePeer` statement shares.
872
+ */
873
+ const REVOCATION_SELECT_SQL = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
874
+ const CADRE_PEER_SELECT_SQL = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
875
+ const OWNER_KEY_SELECT_SQL = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
876
+
877
+ /** A stored `CadrePeer` row as a {@link CadrePeerRow}. */
878
+ function cadrePeerRowOf(row: Record<string, SqlValue>): CadrePeerRow {
879
+ return {
880
+ peerId: row.PeerId as string,
881
+ publicKey: (row.PublicKey as string | null) ?? null,
882
+ multiaddr: (row.Multiaddr as string | null) ?? null,
883
+ updatedAt: (row.UpdatedAt as number | null) ?? 0,
884
+ profile: (row.Profile as NodeProfile | null) ?? null,
885
+ sig: (row.Sig as string | null) ?? null,
886
+ stampId: (row.StampId as string | null) ?? null,
887
+ vouchOwner: (row.VouchOwner as string | null) ?? null,
888
+ vouchSig: (row.VouchSig as string | null) ?? null,
889
+ vouchUsage: (row.VouchUsage as string | null) ?? null,
890
+ };
891
+ }
892
+
893
+ /** A {@link CadrePeerRow} as the address record its machine signed ({@link PeerAddressRecord}); see {@link ControlDatabase.queryPeerRecord} for the coalescing. */
894
+ function peerAddressRecordOf(row: CadrePeerRow): PeerAddressRecord {
895
+ const multiaddr = row.multiaddr ?? '';
896
+ return {
897
+ peerId: row.peerId,
898
+ publicKey: row.publicKey ?? '',
899
+ addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
900
+ updatedAt: row.updatedAt,
901
+ profile: row.profile,
902
+ sig: row.sig ?? '',
903
+ };
904
+ }
905
+
906
+ /** The columns {@link ControlDatabase.reauthorizeCadrePeer} signs over and bumps: a row it may write. */
907
+ interface RetouchTarget {
908
+ stampId: string;
909
+ updatedAt: number;
910
+ }
911
+
912
+ /**
913
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s decision over the row as read: the row to write,
914
+ * or the {@link CadrePeerRetouchOutcome} that says why nothing is written — no readable row (a
915
+ * stampless row counts, since the voucher digest binds the stamp), or a self-published row in
916
+ * `'rebroadcast'` mode. One function so the pre-check outside the lock and the re-check inside it
917
+ * cannot disagree.
918
+ */
919
+ function retouchTarget(row: CadrePeerRow | null, mode: CadrePeerRetouchMode): RetouchTarget | 'absent' | 'self-published' {
920
+ if (row === null || row.stampId === null) {
921
+ return 'absent';
922
+ }
923
+ if (mode === 'rebroadcast' && row.sig !== null) {
924
+ return 'self-published';
925
+ }
926
+ return { stampId: row.stampId, updatedAt: row.updatedAt };
927
+ }
928
+
929
+ /** A stored `Revocation` row as a {@link RevocationRow}; the caller has already skipped {@link REVOCATION_LEDGER_MARKER}. */
930
+ function revocationRowOf(row: Record<string, SqlValue>): RevocationRow {
931
+ return {
932
+ tableName: row.TableName as RevocableTable,
933
+ rowKey: row.RowKey as string,
934
+ stampId: row.StampId as string,
935
+ reissuedAt: (row.ReissuedAt as number | null) ?? 0,
936
+ signerKey: row.SignerKey as string,
937
+ signerSig: row.SignerSig as string,
938
+ };
939
+ }
940
+
941
+ /**
942
+ * Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them: the full scan the
943
+ * `CadreInvite` membership view loads, and the base the point reads append their `where` to.
944
+ */
811
945
  const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
812
946
 
813
947
  /**
@@ -827,6 +961,9 @@ function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
827
961
  };
828
962
  }
829
963
 
964
+ /** The full scan the `CadreInviteUsage` membership view loads, in the order {@link cadreInviteUsageRowOf} reads it. */
965
+ const CADRE_INVITE_USAGE_SELECT_SQL = 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage';
966
+
830
967
  /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
831
968
  function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageRow {
832
969
  return {
@@ -840,7 +977,7 @@ function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageR
840
977
  };
841
978
  }
842
979
 
843
- /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; callers append their `where`. */
980
+ /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; as {@link CADRE_INVITE_SELECT_SQL}, the view's scan and the point read's base. */
844
981
  const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
845
982
 
846
983
  /** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
@@ -856,6 +993,9 @@ function ownerGrantRowOf(row: Record<string, SqlValue>): OwnerGrantRow {
856
993
  };
857
994
  }
858
995
 
996
+ /** The full scan the `OwnerGrantUsage` membership view loads, in the order {@link ownerGrantUsageRowOf} reads it. */
997
+ const OWNER_GRANT_USAGE_SELECT_SQL = 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage';
998
+
859
999
  /** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
860
1000
  function ownerGrantUsageRowOf(row: Record<string, SqlValue>): OwnerGrantUsageRow {
861
1001
  return {
@@ -901,16 +1041,8 @@ function strandRow(row: Record<string, SqlValue>): StrandRow {
901
1041
  const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
902
1042
 
903
1043
  /** A `FormationInvite` row as {@link ControlDatabase.queryFormationInvite} reads it. */
904
- export interface FormationInviteRow {
1044
+ export interface FormationInviteRow extends FormationInviteFacts {
905
1045
  token: string;
906
- sAppId: string;
907
- /** Parsed epoch ms of the stored `datetime`; null when the invitation never expires. */
908
- expiresAtMs: number | null;
909
- /** Null is unlimited. */
910
- totalUses: number | null;
911
- validationUrl: string | null;
912
- /** A bound invitation's host strand; null on the responder-provisions path. */
913
- strandId: string | null;
914
1046
  }
915
1047
 
916
1048
  const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
@@ -1120,6 +1252,7 @@ export interface ControlDatabaseConfig {
1120
1252
  export class ControlDatabase {
1121
1253
  private db: Database | null = null;
1122
1254
  private collectionFactory: CollectionFactory | null = null;
1255
+ private txnBridge: CollectionRegistrySource | null = null;
1123
1256
  private readonly config: ControlDatabaseConfig;
1124
1257
  private initialized = false;
1125
1258
  private membershipListener: MembershipChangeListener | null = null;
@@ -1127,6 +1260,26 @@ export class ControlDatabase {
1127
1260
  private controlWriteAbandonedListener: ControlWriteAbandonedListener | null = null;
1128
1261
  /** Tail of the local-write chain — see {@link withWriteLock}. */
1129
1262
  private writeQueue: Promise<unknown> = Promise.resolve();
1263
+ /** Subscriptions handed out by {@link watchTables}. */
1264
+ private readonly tableWatches = new TableWatchRegistry();
1265
+ /** The cached membership tables; see {@link readMembershipRows}. */
1266
+ private readonly revocationView = this.membershipView(REVOCATION_SELECT_SQL, 'revocations');
1267
+ private readonly cadrePeerView = this.membershipView(CADRE_PEER_SELECT_SQL, 'cadre-peers');
1268
+ private readonly ownerKeyView = this.membershipView(OWNER_KEY_SELECT_SQL, 'owner-keys');
1269
+ private readonly cadreInviteView = this.membershipView(CADRE_INVITE_SELECT_SQL, 'cadre-invites');
1270
+ private readonly cadreInviteUsageView = this.membershipView(CADRE_INVITE_USAGE_SELECT_SQL, 'cadre-invite-usages');
1271
+ private readonly ownerGrantView = this.membershipView(OWNER_GRANT_SELECT_SQL, 'owner-grants');
1272
+ private readonly ownerGrantUsageView = this.membershipView(OWNER_GRANT_USAGE_SELECT_SQL, 'owner-grant-usages');
1273
+ /** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
1274
+ private readonly membershipViews = new Map<string, MembershipView<Record<string, SqlValue>>>([
1275
+ ['CadreControl.Revocation', this.revocationView],
1276
+ ['CadreControl.CadrePeer', this.cadrePeerView],
1277
+ ['CadreControl.OwnerKey', this.ownerKeyView],
1278
+ ['CadreControl.CadreInvite', this.cadreInviteView],
1279
+ ['CadreControl.CadreInviteUsage', this.cadreInviteUsageView],
1280
+ ['CadreControl.OwnerGrant', this.ownerGrantView],
1281
+ ['CadreControl.OwnerGrantUsage', this.ownerGrantUsageView],
1282
+ ]);
1130
1283
  /**
1131
1284
  * Locked write bodies currently running (see {@link runWriteBody}); what
1132
1285
  * {@link readRowsOnce} consults to route an unlocked read around a write that has
@@ -1166,6 +1319,7 @@ export class ControlDatabase {
1166
1319
  }
1167
1320
 
1168
1321
  log('Initializing ControlDatabase for party: %s', this.config.partyId);
1322
+ this.invalidateMembershipViews();
1169
1323
 
1170
1324
  // Create database instance
1171
1325
  this.db = new Database();
@@ -1199,6 +1353,7 @@ export class ControlDatabase {
1199
1353
  registerPartyIdFunction(this.db, this.config.partyId);
1200
1354
 
1201
1355
  this.collectionFactory = pluginResult.collectionFactory;
1356
+ this.txnBridge = pluginResult.txnBridge;
1202
1357
 
1203
1358
  // Inject the libp2p node into the collection factory
1204
1359
  t0 = performance.now();
@@ -1255,10 +1410,49 @@ export class ControlDatabase {
1255
1410
  await this.loadSchema();
1256
1411
  timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
1257
1412
 
1413
+ this.watchMembershipViews();
1258
1414
  this.initialized = true;
1259
1415
  log('ControlDatabase initialized successfully');
1260
1416
  }
1261
1417
 
1418
+ /**
1419
+ * Drop a membership view whenever its table changes: a commit made here, or a change this
1420
+ * node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
1421
+ * does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
1422
+ * subscription cannot hear. {@link close} disposes it with the rest.
1423
+ */
1424
+ private watchMembershipViews(): void {
1425
+ this.tableWatches.watch(this.db!, [...this.membershipViews.keys()], () => Promise.resolve(), {
1426
+ label: 'membership-view',
1427
+ onInvalidate: (table) => this.membershipViews.get(table)?.invalidate(),
1428
+ });
1429
+ }
1430
+
1431
+ /**
1432
+ * {@link watchTables} on the membership tables: the seven the membership views copy
1433
+ * (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
1434
+ * `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
1435
+ * read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
1436
+ * reads can depend on and no list of them lives outside this class.
1437
+ */
1438
+ watchMembershipTables(onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
1439
+ return this.watchTables([...this.membershipViews.keys()], onChange, options);
1440
+ }
1441
+
1442
+ /**
1443
+ * Empty every membership view, so the next read of each loads afresh and no load in
1444
+ * flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
1445
+ * a sibling becomes reachable (its identify on a new connection): a machine that was offline
1446
+ * while that sibling committed learns of the commit only when it next reads the block from
1447
+ * storage, and dropping the views makes the pass that starts then read now rather than after
1448
+ * `MEMBERSHIP_VIEW_MAX_AGE_MS`.
1449
+ */
1450
+ invalidateMembershipViews(): void {
1451
+ for (const view of this.membershipViews.values()) {
1452
+ view.invalidate();
1453
+ }
1454
+ }
1455
+
1262
1456
  private async loadSchema(): Promise<void> {
1263
1457
  let declaration: string;
1264
1458
  if (this.config.schemaPath) {
@@ -1271,10 +1465,12 @@ export class ControlDatabase {
1271
1465
 
1272
1466
  // Declaring runs no DDL; it only records the declaration the check and the apply both read.
1273
1467
  await this.db!.exec(declaration);
1274
- // A warm store this build cannot apply to without losing data is refused HERE, before the
1275
- // apply runs anything: the optimystic module refuses most changes to an existing table
1276
- // part-way through an apply, and a dropped table takes its rows. Migrating a store in place
1277
- // (control-store-migrates-on-start) widens this check to let catalog-only changes through.
1468
+ // The apply below is also the MIGRATION of a warm store written under an older control
1469
+ // schema version: its diff runs each version's changes in order, every one applied to a
1470
+ // table's catalog record without a row rewritten, and ends by recording this build's version
1471
+ // on the store. A store the apply must not touch is refused HERE, before it runs anything: one
1472
+ // a newer release migrated (this machine is the one to update), one from before migrations,
1473
+ // or one whose diff would rewrite or discard rows (`control-schema-version.ts`).
1278
1474
  await refuseIncompatibleControlStore(this.db!, this.config.partyId);
1279
1475
  await this.applyControlSchema();
1280
1476
  log('Schema loaded and executed');
@@ -1318,6 +1514,20 @@ export class ControlDatabase {
1318
1514
  // argument is a statement about the pair, not about Quereus alone — pinned by
1319
1515
  // `test/control-schema-apply-unwind.spec.ts`.
1320
1516
  //
1517
+ // The same argument covers the statements a MIGRATION runs (`control-schema-version.ts`'s
1518
+ // `catalog-only` kinds, from Optimystic 1.16.0): the plugin's `alterTable` arms (add column,
1519
+ // drop column, add and drop CHECK) and the tag change write the table's catalog record under
1520
+ // the batch's per-statement checkpoint, so a step that throws withdraws its own write, and a
1521
+ // batch whose commit fails is discarded whole and the tables it wrote re-persist on their next
1522
+ // touch. A change the record already holds — another machine migrated first — is adopted with
1523
+ // nothing written, and an undo of an adopted change touches this machine's catalog only. The
1524
+ // one arm Quereus marks irreversible is `drop column`: once it has run, a later failure
1525
+ // unwinds nothing (the apply reports the schema as partially migrated), the plugin's
1526
+ // `endSchemaBatch` commits the steps that landed — or, when that commit fails, marks their
1527
+ // tables to re-persist on next touch — and the retry's diff runs only the steps still
1528
+ // missing. No row is lost either way: rows keep a dropped column's field until they are
1529
+ // rewritten, and nothing reads it.
1530
+ //
1321
1531
  // Only the "cohort did not answer, nothing committed" class is retried; the classifier
1322
1532
  // vetoes indeterminate commits and does not match `Missing block`, which is a durable
1323
1533
  // convergence fault a retry cannot heal (tracked separately) and must keep propagating.
@@ -1409,9 +1619,10 @@ export class ControlDatabase {
1409
1619
  * behind back-to-back local writes, narrow the count to the span from a body's first
1410
1620
  * statement to its transaction opening.
1411
1621
  */
1412
- private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<Record<string, SqlValue>[]> {
1622
+ private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<RoutedRead> {
1623
+ const committed = this.writeInFlight(underWriteLock);
1413
1624
  let iterator: AsyncIterableIterator<Record<string, SqlValue>>;
1414
- if (this.writeInFlight(underWriteLock)) {
1625
+ if (committed) {
1415
1626
  log('read-eval: a write is in flight (%s), asking for a committed read: %s',
1416
1627
  this.db!.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
1417
1628
  iterator = this.db!.eval(sql, params, { readConcurrency: 'committed' });
@@ -1422,7 +1633,7 @@ export class ControlDatabase {
1422
1633
  for await (const row of iterator) {
1423
1634
  rows.push(row);
1424
1635
  }
1425
- return rows;
1636
+ return { rows, committed };
1426
1637
  }
1427
1638
 
1428
1639
  /**
@@ -1482,12 +1693,26 @@ export class ControlDatabase {
1482
1693
  * held, and its membership read is opted out at {@link queryCadrePeers}. If another
1483
1694
  * under-lock callback seam is ever added, audit its read graph the same way.
1484
1695
  */
1485
- private readRows(
1696
+ private async readRows(
1486
1697
  sql: string,
1487
1698
  params: SqlParameters | undefined,
1488
1699
  label: string,
1489
1700
  retry = true
1490
1701
  ): Promise<Record<string, SqlValue>[]> {
1702
+ return (await this.readRowsRouted(sql, params, label, retry)).rows;
1703
+ }
1704
+
1705
+ /**
1706
+ * {@link readRows}, also saying whether the attempt that answered took the committed-read
1707
+ * path — which a {@link MembershipView} load needs, since it may not keep such rows (see
1708
+ * {@link membershipView}).
1709
+ */
1710
+ private readRowsRouted(
1711
+ sql: string,
1712
+ params: SqlParameters | undefined,
1713
+ label: string,
1714
+ retry: boolean
1715
+ ): Promise<RoutedRead> {
1491
1716
  // `retry: false` marks the reads that may run inside a locked write body, which is
1492
1717
  // also what readRowsOnce's committed-read routing must know (see writeInFlight).
1493
1718
  // NOTE: the membership-gate refresh passes it on EVERY trigger, including the
@@ -1506,6 +1731,46 @@ export class ControlDatabase {
1506
1731
  );
1507
1732
  }
1508
1733
 
1734
+ /**
1735
+ * A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
1736
+ * funnel. Rows a committed read served are never kept: that read may initialize an untouched
1737
+ * table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
1738
+ * on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
1739
+ * event would ever drop them.
1740
+ */
1741
+ private membershipView(sql: string, label: string): MembershipView<Record<string, SqlValue>> {
1742
+ return new MembershipView(async (retry) => {
1743
+ const { rows, committed } = await this.readRowsRouted(sql, undefined, label, retry);
1744
+ return { rows, installable: !committed };
1745
+ });
1746
+ }
1747
+
1748
+ /**
1749
+ * A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
1750
+ * and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
1751
+ * `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
1752
+ * membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
1753
+ * Callers build fresh result objects from them and never mutate them.
1754
+ *
1755
+ * Reads the table directly instead when a caller under the write lock (`retry: false`) has an
1756
+ * explicit transaction open: such a read sees the transaction's own uncommitted rows, which
1757
+ * the view does not hold, and a rollback fires no change event, so keeping them would cache
1758
+ * rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
1759
+ * this keeps one added later, or a spec's raw-handle transaction, correct.
1760
+ *
1761
+ * Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
1762
+ * schema's CHECK constraints, which read inside the committing transaction, the redemption's
1763
+ * one read of the held credential row ({@link queryCadreInvite},
1764
+ * {@link queryOwnerGrant}), and the indexed usage counts.
1765
+ */
1766
+ private readMembershipRows(view: MembershipView<Record<string, SqlValue>>, retry: boolean): Promise<readonly Record<string, SqlValue>[]> {
1767
+ this.ensureInitialized();
1768
+ if (!retry && !this.db!.getAutocommit()) {
1769
+ return view.readThrough(retry);
1770
+ }
1771
+ return view.read(retry);
1772
+ }
1773
+
1509
1774
  /**
1510
1775
  * Query all strands from the control database
1511
1776
  */
@@ -1527,6 +1792,18 @@ export class ControlDatabase {
1527
1792
  return rows.length === 0 ? null : strandRow(rows[0]!);
1528
1793
  }
1529
1794
 
1795
+ /**
1796
+ * {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
1797
+ * resolves a bound invite's host strand with it and later records consent against that
1798
+ * stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
1799
+ */
1800
+ async queryStrandWithStamp(strandId: string): Promise<(StrandRow & { StampId: string }) | null> {
1801
+ this.ensureInitialized();
1802
+ const rows = await this.readRows(
1803
+ `select ${STRAND_COLUMNS}, StampId from CadreControl.Strand where Id = ?`, [strandId], 'strand-with-stamp');
1804
+ return rows.length === 0 ? null : { ...strandRow(rows[0]!), StampId: rows[0]!.StampId as string };
1805
+ }
1806
+
1530
1807
  /**
1531
1808
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
1532
1809
  * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
@@ -1616,13 +1893,13 @@ export class ControlDatabase {
1616
1893
  *
1617
1894
  * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1618
1895
  * insert is refused by the schema whenever any physical row exists, so this must see one.
1896
+ * Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
1897
+ * physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
1898
+ * one storage read.
1619
1899
  */
1620
1900
  async hasOwnerKey(): Promise<boolean> {
1621
1901
  this.ensureInitialized();
1622
- for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
1623
- return (row.Count as number) > 0;
1624
- }
1625
- return false;
1902
+ return (await this.readMembershipRows(this.ownerKeyView, true)).length > 0;
1626
1903
  }
1627
1904
 
1628
1905
  /**
@@ -1630,6 +1907,16 @@ export class ControlDatabase {
1630
1907
  * the party has none yet. Returns true if it inserted, false if an owner
1631
1908
  * key already existed (so a repeat `--owner` start is a no-op).
1632
1909
  *
1910
+ * Before the insert, gives every control table its empty collection
1911
+ * ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
1912
+ * forks if another machine creates the same collection independently; before genesis the
1913
+ * party has no owner, so no other machine can have been admitted and none can hold any of
1914
+ * its collections. The order makes the step repeatable: a crash between it and the insert
1915
+ * leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
1916
+ * collections still missing, while a founder restart finds the row and commits nothing, so
1917
+ * it can never write an empty second history beside a table another machine has since
1918
+ * written. A failure propagates with nothing inserted.
1919
+ *
1633
1920
  * @param label - the user's name for the key ({@link insertOwnerKey}).
1634
1921
  */
1635
1922
  async ensureOwnerKey(key: string, label?: string): Promise<boolean> {
@@ -1639,10 +1926,42 @@ export class ControlDatabase {
1639
1926
  log('Owner key already present; skipping genesis insert');
1640
1927
  return false;
1641
1928
  }
1929
+ // Two locked steps one after the other: the lock is not re-entrant, and the insert takes it itself.
1930
+ await this.commitEmptyControlCollections();
1642
1931
  await this.insertOwnerKey(trimmed, label);
1643
1932
  return true;
1644
1933
  }
1645
1934
 
1935
+ /**
1936
+ * Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
1937
+ * tree that has none, so a read of a table nobody has written is answered from this machine's
1938
+ * storage instead of asking the block's cohort to prove the collection absent — a round trip
1939
+ * per read on a party of several machines, and an error or a wait while they are offline.
1940
+ * Joining machines get these collections the ordinary way, by reading them from the cohort.
1941
+ *
1942
+ * Under the write lock: the helper commits whatever is staged in those collections outside any
1943
+ * SQL transaction, which is sound only while nothing else writes.
1944
+ *
1945
+ * Parties founded before this step, and tables a later control schema version adds, have no
1946
+ * such collections, and nothing creates them later: the start-time schema apply runs on every
1947
+ * machine, possibly alone, where committing one would be exactly the fork genesis avoids.
1948
+ *
1949
+ * NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
1950
+ * 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
1951
+ * transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
1952
+ * 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
1953
+ * node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
1954
+ * geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
1955
+ * (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
1956
+ * `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
1957
+ * founding would use too; do not build a private batching path.
1958
+ */
1959
+ private async commitEmptyControlCollections(): Promise<void> {
1960
+ const t0 = performance.now();
1961
+ const committed = await this.withWriteLock(() => createEmptyCollections(this.db!, this.txnBridge!, [CONTROL_SCHEMA_NAME]));
1962
+ log('Committed %d empty control collections in %dms', committed, Math.round(performance.now() - t0));
1963
+ }
1964
+
1646
1965
  /**
1647
1966
  * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
1648
1967
  *
@@ -1684,16 +2003,14 @@ export class ControlDatabase {
1684
2003
  *
1685
2004
  * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1686
2005
  * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1687
- * signers); omitted, they are read here. Before the ledger marker is filed every
1688
- * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1689
- * membership refresh at one such read for the anchor.
2006
+ * signers); omitted, they are read here. Both come from the same `Revocation` view
2007
+ * ({@link readMembershipRows}), so passing the set saves only the filtering.
1690
2008
  */
1691
2009
  async queryOwnerKeyRows(retry = true, retired?: ReadonlySet<string>): Promise<OwnerKeyRow[]> {
1692
2010
  this.ensureInitialized();
1693
2011
  const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1694
2012
  const rows: OwnerKeyRow[] = [];
1695
- const sql = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1696
- for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
2013
+ for (const row of await this.readMembershipRows(this.ownerKeyView, retry)) {
1697
2014
  const stampId = (row.StampId as string | null) ?? null;
1698
2015
  if (stampId === null) {
1699
2016
  log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
@@ -1726,12 +2043,8 @@ export class ControlDatabase {
1726
2043
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
1727
2044
  * that seam to model the replicated live-row-plus-tombstone merge state.
1728
2045
  *
1729
- * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
1730
- * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
1731
- * while `Revocation` has never been written every call consults the cohort about it, and
1732
- * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
1733
- * the exclusion into one statement would not help (it reads the same block), and caching
1734
- * the set would hide a tombstone arriving by replication.
2046
+ * Both tables come from their membership views ({@link readMembershipRows}), so a call after
2047
+ * the first costs no storage read until one of the two tables changes.
1735
2048
  *
1736
2049
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
1737
2050
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -1754,27 +2067,12 @@ export class ControlDatabase {
1754
2067
  this.ensureInitialized();
1755
2068
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1756
2069
  const rows: CadrePeerRow[] = [];
1757
- // The proof columns lead and the record columns follow, so this scan keeps a prefix no
1758
- // other `CadrePeer` statement shares (`queryPeerRecord` starts `PeerId, PublicKey`): the
1759
- // read-retry spec rigs the membership scan by that prefix.
1760
- const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
1761
- for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
1762
- const stampId = (row.StampId as string | null) ?? null;
1763
- if (stampId !== null && revoked.has(stampId)) {
2070
+ for (const row of await this.readMembershipRows(this.cadrePeerView, retry)) {
2071
+ const peer = cadrePeerRowOf(row);
2072
+ if (peer.stampId !== null && revoked.has(peer.stampId)) {
1764
2073
  continue;
1765
2074
  }
1766
- rows.push({
1767
- peerId: row.PeerId as string,
1768
- publicKey: (row.PublicKey as string | null) ?? null,
1769
- multiaddr: (row.Multiaddr as string | null) ?? null,
1770
- updatedAt: (row.UpdatedAt as number | null) ?? 0,
1771
- profile: (row.Profile as NodeProfile | null) ?? null,
1772
- sig: (row.Sig as string | null) ?? null,
1773
- stampId,
1774
- vouchOwner: (row.VouchOwner as string | null) ?? null,
1775
- vouchSig: (row.VouchSig as string | null) ?? null,
1776
- vouchUsage: (row.VouchUsage as string | null) ?? null,
1777
- });
2075
+ rows.push(peer);
1778
2076
  }
1779
2077
  return rows;
1780
2078
  }
@@ -1831,10 +2129,11 @@ export class ControlDatabase {
1831
2129
  * guard. A read retrying there would sleep its backoff HOLDING the write lock
1832
2130
  * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
1833
2131
  * the write funnel already re-runs the body's reads when it re-runs the body. The
1834
- * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
1835
- * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
1836
- * default retried path. Explicit per call rather than an ambient flag: unlocked reads
1837
- * run concurrently with a locked body, so `this`-state cannot tell the two apart.
2132
+ * unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
2133
+ * read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
2134
+ * rather than an ambient flag: unlocked reads run concurrently with a locked body, so
2135
+ * `this`-state cannot tell the two apart. The same flag threads through
2136
+ * {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
1838
2137
  */
1839
2138
  private async queryStampId(
1840
2139
  table: RevocableTable,
@@ -1852,8 +2151,8 @@ export class ControlDatabase {
1852
2151
  /**
1853
2152
  * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
1854
2153
  * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
1855
- * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
1856
- * straight to {@link queryStampId}.
2154
+ * `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
2155
+ * the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
1857
2156
  */
1858
2157
  queryCadrePeerStampId(peerId: string): Promise<string | null> {
1859
2158
  return this.queryStampId('CadrePeer', peerId);
@@ -1919,18 +2218,17 @@ export class ControlDatabase {
1919
2218
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
1920
2219
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
1921
2220
  *
1922
- * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
1923
- * `Revocation` has never been written, this node does not hold its block, and Optimystic
1924
- * consults a block's cohort on every read of a block it does not hold: 1 consult per
1925
- * call (measured), each a round trip to every other member on a multi-machine party. The
1926
- * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
1927
- * at most once per read-repair window (10 s). Do not cache the set instead: a cache
1928
- * cannot see a tombstone arriving by replication, so it would delay a revocation. The
1929
- * row count only grows (append-only) but stays small while removals are rare; revisit if
2221
+ * Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
2222
+ * every change to the table, local or replicated into this node's storage, so a tombstone
2223
+ * arriving by replication is seen on the next call. The cost that matters is the storage
2224
+ * layer's, paid by a cold view's load: while `Revocation` has never been written, this node
2225
+ * does not hold its block, and Optimystic consults a block's cohort on every read of a block
2226
+ * it does not hold, each a round trip to every other member on a multi-machine party. The row
2227
+ * count only grows (append-only) but stays small while removals are rare; revisit if
1930
2228
  * removals become routine and the scan itself shows up in a profile.
1931
2229
  *
1932
2230
  * An isolated node that has never received the `Revocation` block reads it as holding no
1933
- * revocations instead of throwing — see {@link readRevokedStampRows}.
2231
+ * revocations instead of throwing — see {@link readRevocationRowsOrNone}.
1934
2232
  *
1935
2233
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
1936
2234
  * under the write lock — see that method's note.
@@ -1938,22 +2236,26 @@ export class ControlDatabase {
1938
2236
  async queryRevokedStamps(tableName: RevocableTable, retry = true): Promise<Set<string>> {
1939
2237
  this.ensureInitialized();
1940
2238
  const stamps = new Set<string>();
1941
- for (const row of await this.readRevokedStampRows(tableName, retry)) {
1942
- stamps.add(row.StampId as string);
2239
+ for (const row of await this.readRevocationRowsOrNone(retry)) {
2240
+ if (row.TableName === tableName) {
2241
+ stamps.add(row.StampId as string);
2242
+ }
1943
2243
  }
1944
2244
  return stamps;
1945
2245
  }
1946
2246
 
1947
2247
  /**
1948
- * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
1949
- * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
2248
+ * The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
2249
+ * load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1950
2250
  * the block and could ask no other cohort member about it. Every other failure rethrows.
1951
2251
  * The retry in {@link readRows} runs first, so a node whose connectivity returns within
1952
- * the read budget still gets the real answer.
2252
+ * the read budget still gets the real answer. The empty answer is never installed in the
2253
+ * view (a failed load keeps nothing), so the next call asks again.
1953
2254
  *
1954
2255
  * Without this, a machine cut off from every other machine in its party before it ever
1955
- * received the block — the block does not exist anywhere until the owner files the
1956
- * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
2256
+ * received the block — a joining machine gets it only by reading it from the cohort, and
2257
+ * on a party founded before genesis created every control collection it exists nowhere
2258
+ * until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
1957
2259
  * answer ANY membership, peer-record or device-token lookup, although it holds those
1958
2260
  * rows locally.
1959
2261
  *
@@ -1972,19 +2274,19 @@ export class ControlDatabase {
1972
2274
  *
1973
2275
  * NOTE: an isolated node pays the whole read retry (up to
1974
2276
  * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
1975
- * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
1976
- * node's lookups are seen exceeding the admission gate's 2 s deadline
1977
- * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
1978
- * fails `cohort-unreachable`.
2277
+ * answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
2278
+ * load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
2279
+ * admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
2280
+ * read when the first attempt fails `cohort-unreachable`.
1979
2281
  */
1980
- private async readRevokedStampRows(tableName: RevocableTable, retry: boolean): Promise<Record<string, SqlValue>[]> {
2282
+ private async readRevocationRowsOrNone(retry: boolean): Promise<readonly Record<string, SqlValue>[]> {
1981
2283
  try {
1982
- return await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry);
2284
+ return await this.readMembershipRows(this.revocationView, retry);
1983
2285
  } catch (error) {
1984
2286
  if (!isCohortUnreachableRead(error)) {
1985
2287
  throw error;
1986
2288
  }
1987
- log('revoked-stamps(%s): no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', tableName, error);
2289
+ log('revoked-stamps: no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', error);
1988
2290
  return [];
1989
2291
  }
1990
2292
  }
@@ -1997,10 +2299,10 @@ export class ControlDatabase {
1997
2299
  * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1998
2300
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1999
2301
  *
2000
- * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
2001
- * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
2002
- * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
2003
- * admits no other row under `'Revocation'`.
2302
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
2303
+ * retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
2304
+ * `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
2305
+ * TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
2004
2306
  *
2005
2307
  * `retry: false` from a caller that reads under the write lock, for the reason on
2006
2308
  * {@link queryCadrePeers}.
@@ -2008,25 +2310,17 @@ export class ControlDatabase {
2008
2310
  async queryRevocations(retry = true): Promise<RevocationRow[]> {
2009
2311
  this.ensureInitialized();
2010
2312
  const rows: RevocationRow[] = [];
2011
- const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
2012
- for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
2013
- if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
2014
- continue;
2313
+ for (const row of await this.readMembershipRows(this.revocationView, retry)) {
2314
+ if (row.TableName !== REVOCATION_LEDGER_MARKER.tableName) {
2315
+ rows.push(revocationRowOf(row));
2015
2316
  }
2016
- rows.push({
2017
- tableName: row.TableName as RevocableTable,
2018
- rowKey: row.RowKey as string,
2019
- stampId: row.StampId as string,
2020
- reissuedAt: (row.ReissuedAt as number | null) ?? 0,
2021
- signerKey: row.SignerKey as string,
2022
- signerSig: row.SignerSig as string,
2023
- });
2024
2317
  }
2025
2318
  return rows;
2026
2319
  }
2027
2320
 
2028
2321
  /**
2029
- * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
2322
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
2323
+ * `CadrePeer` membership view ({@link readMembershipRows}).
2030
2324
  *
2031
2325
  * Returns null when no row exists, or when the row's `StampId` is retired in
2032
2326
  * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
@@ -2042,34 +2336,36 @@ export class ControlDatabase {
2042
2336
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
2043
2337
  * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
2044
2338
  * the publisher signed.
2339
+ *
2340
+ * `retry: false` from the one caller that reads under the write lock,
2341
+ * {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
2045
2342
  */
2046
- async queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null> {
2343
+ async queryPeerRecord(peerId: string, retry = true): Promise<PeerAddressRecord | null> {
2344
+ const row = await this.findCadrePeerRow(peerId, retry);
2345
+ return row === null ? null : peerAddressRecordOf(row);
2346
+ }
2347
+
2348
+ /**
2349
+ * One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
2350
+ * or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
2351
+ * and the two writers that decide over the row inside their locked body
2352
+ * ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
2353
+ * {@link queryCadrePeers}, and the same `retry` contract.
2354
+ */
2355
+ private async findCadrePeerRow(peerId: string, retry: boolean): Promise<CadrePeerRow | null> {
2047
2356
  this.ensureInitialized();
2048
- const revoked = await this.queryRevokedStamps('CadrePeer');
2049
- for (const row of await this.readRows(
2050
- 'select PeerId, PublicKey, Multiaddr, UpdatedAt, Profile, Sig, StampId from CadreControl.CadrePeer where PeerId = ?',
2051
- [peerId],
2052
- 'peer-record'
2053
- )) {
2054
- const stampId = (row.StampId as string | null) ?? null;
2055
- if (stampId !== null && revoked.has(stampId)) {
2056
- return null;
2057
- }
2058
- const multiaddr = (row.Multiaddr as string | null) ?? '';
2059
- return {
2060
- peerId: row.PeerId as string,
2061
- publicKey: (row.PublicKey as string | null) ?? '',
2062
- addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
2063
- updatedAt: (row.UpdatedAt as number | null) ?? 0,
2064
- profile: (row.Profile as NodeProfile | null) ?? null,
2065
- sig: (row.Sig as string | null) ?? '',
2066
- };
2357
+ const revoked = await this.queryRevokedStamps('CadrePeer', retry);
2358
+ const row = (await this.readMembershipRows(this.cadrePeerView, retry)).find((candidate) => candidate.PeerId === peerId);
2359
+ if (row === undefined) {
2360
+ return null;
2067
2361
  }
2068
- return null;
2362
+ const peer = cadrePeerRowOf(row);
2363
+ return peer.stampId !== null && revoked.has(peer.stampId) ? null : peer;
2069
2364
  }
2070
2365
 
2071
2366
  /**
2072
- * Apply a peer's own self-signed address-record update to an existing row.
2367
+ * Apply a peer's own self-signed address-record update to its existing row: read the row
2368
+ * under the write lock, have `sign` build the record over it, and write that record.
2073
2369
  *
2074
2370
  * Authorization is carried entirely by the record: the `Sig` column (verified
2075
2371
  * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
@@ -2078,6 +2374,21 @@ export class ControlDatabase {
2078
2374
  * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
2079
2375
  * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
2080
2376
  *
2377
+ * The read, the decision and the signature all run INSIDE the locked body, which is what
2378
+ * lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
2379
+ * re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
2380
+ * When this write loses that race the commit is refused as a lost update and re-run, and the
2381
+ * re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
2382
+ * signed before the race could no longer exceed — and signs past it. A record signed outside
2383
+ * the body would be re-presented unchanged and refused again, this time by the schema's
2384
+ * monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
2385
+ * (see {@link queryStampId}'s NOTE).
2386
+ *
2387
+ * Returns the record actually written, so the caller's bookkeeping describes the row rather
2388
+ * than the first record it signed; null, and nothing written, when no readable row exists:
2389
+ * the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
2390
+ * as absent every heartbeat, and must not refresh it).
2391
+ *
2081
2392
  * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
2082
2393
  * is not: it only ever touches THIS node's own row (sole caller
2083
2394
  * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
@@ -2090,17 +2401,42 @@ export class ControlDatabase {
2090
2401
  * row, and a self insert happens once at startup, so the wasted refresh is a single read
2091
2402
  * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
2092
2403
  * path is worth exempting.
2404
+ *
2405
+ * @param peerId - the row to refresh: this node's own.
2406
+ * @param sign - builds the signed record over the row as read; called once per attempt.
2093
2407
  */
2094
- async updateSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
2408
+ async updateSelfPeerRecord(peerId: string, sign: SelfRecordSigner): Promise<PeerAddressRecord | null> {
2095
2409
  this.ensureInitialized();
2096
- const multiaddr = record.addrs.join(',');
2097
- await this.execWrite(`
2098
- update CadreControl.CadrePeer
2099
- with context OwnerKey = null, Signature = ?
2100
- set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
2101
- where PeerId = ?
2102
- `, [record.sig, multiaddr, record.updatedAt, record.profile, record.sig, record.peerId], 'self-record-update');
2103
- log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
2410
+ const written = await this.lockedWithRetry(async () => {
2411
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2412
+ // NOTE: this read of the Revocation and CadrePeer views is charged to the retry budget,
2413
+ // so a cold view load that waits out a per-peer read deadline leaves the attempt no room
2414
+ // for its one retry (the 14.7 s abandonment the policy's doc cites). The caller's read
2415
+ // outside the funnel warms the views, so this is a cache hit except when a table change
2416
+ // between the two reads dropped them — the racing case itself. If that residual ever
2417
+ // matters, measure which phase of the losing attempt pays the deadline first; the
2418
+ // retired-stamp check cannot simply move outside, since `publishSelfRecord`'s insert
2419
+ // path reads a revoked node's row as absent and relies on this body to skip it.
2420
+ const current = await this.queryPeerRecord(peerId, false);
2421
+ if (current === null) {
2422
+ return null;
2423
+ }
2424
+ const record = sign(current);
2425
+ // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2426
+ await this.db!.exec(`
2427
+ update CadreControl.CadrePeer
2428
+ with context OwnerKey = null, Signature = ?
2429
+ set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
2430
+ where PeerId = ?
2431
+ `, [record.sig, record.addrs.join(','), record.updatedAt, record.profile, record.sig, peerId]);
2432
+ return record;
2433
+ }, LOST_UPDATE_RETRY_POLICY, 'self-record-update');
2434
+ if (written === null) {
2435
+ log('Self peer record not updated: no readable CadrePeer row for %s (absent, or its stamp retired)', peerId);
2436
+ } else {
2437
+ log('Self peer record updated: %s (updatedAt=%d)', peerId, written.updatedAt);
2438
+ }
2439
+ return written;
2104
2440
  }
2105
2441
 
2106
2442
  /**
@@ -2366,6 +2702,13 @@ export class ControlDatabase {
2366
2702
  * body, and a row whose stamp is already retired (held without its delete) is skipped rather
2367
2703
  * than tombstoned twice. Nothing to remove writes nothing and returns all false.
2368
2704
  *
2705
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
2706
+ * stamp and signature is minted inside the body, and the invitation keys come from the
2707
+ * caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
2708
+ * since collides on `Revocation`'s primary key — a constraint failure, never retried, as
2709
+ * before). A lost update after a sibling tree of the transaction already synced arrives as a
2710
+ * partial commit and stays declined.
2711
+ *
2369
2712
  * The caller decides which invitations the machine could use, from its plan
2370
2713
  * (`CadreNode.planMachineRemoval`).
2371
2714
  */
@@ -2402,7 +2745,7 @@ export class ControlDatabase {
2402
2745
  removedDeviceToken: removed.has('DeviceToken'),
2403
2746
  withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
2404
2747
  };
2405
- });
2748
+ }, LOST_UPDATE_RETRY_POLICY);
2406
2749
  }
2407
2750
 
2408
2751
  /**
@@ -2461,7 +2804,19 @@ export class ControlDatabase {
2461
2804
  ): Promise<void> {
2462
2805
  this.ensureInitialized();
2463
2806
  log('Inserting strand: %s (type: %s)', strandId, type);
2807
+ const insert = await this.signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey);
2808
+ await this.execWrite(insert.sql, insert.params, 'strand-insert');
2809
+ log('Strand inserted: %s', strandId);
2810
+ }
2464
2811
 
2812
+ /** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
2813
+ private async signStrandInsert(
2814
+ strandId: string,
2815
+ type: 'o' | 'c',
2816
+ ownerKey: string,
2817
+ signMessage: ControlSign,
2818
+ memberPrivateKey?: string
2819
+ ): Promise<SignedInsert> {
2465
2820
  // Generate a unique stamp ID using the peer ID for distributed uniqueness
2466
2821
  const peerId = this.config.libp2pNode.peerId.toString();
2467
2822
  const stampId = generateStampId(peerId);
@@ -2475,13 +2830,55 @@ export class ControlDatabase {
2475
2830
 
2476
2831
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
2477
2832
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
2478
- await this.execWrite(`
2479
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2480
- with context OwnerKey = ?, Signature = ?
2481
- values (?, ?, ?, ?, ?, ?)
2482
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId], 'strand-insert');
2833
+ return {
2834
+ sql: `
2835
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2836
+ with context OwnerKey = ?, Signature = ?
2837
+ values (?, ?, ?, ?, ?, ?)
2838
+ `,
2839
+ params: [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId],
2840
+ };
2841
+ }
2483
2842
 
2484
- log('Strand inserted: %s', strandId);
2843
+ /**
2844
+ * Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
2845
+ * row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
2846
+ * never a strand row without the key or invitation written beside it.
2847
+ *
2848
+ * Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
2849
+ * {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
2850
+ * invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
2851
+ * taken: signing may prompt the user, and the lock held across a prompt would stall every
2852
+ * other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
2853
+ *
2854
+ * No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
2855
+ * `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
2856
+ * `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
2857
+ *
2858
+ * A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
2859
+ * again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
2860
+ * stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
2861
+ */
2862
+ async insertFoundingRows(rows: FoundingRows, ownerKey: string, signMessage: ControlSign): Promise<void> {
2863
+ this.ensureInitialized();
2864
+ const { strand, partyKey, formationInvite } = rows;
2865
+ log('Inserting founding rows: strand %s (type: %s, party key: %s, invitation: %s)',
2866
+ strand.id, strand.type, partyKey !== undefined, formationInvite !== undefined);
2867
+ const inserts = [await this.signStrandInsert(strand.id, strand.type, ownerKey, signMessage, strand.memberPrivateKey)];
2868
+ if (partyKey !== undefined) {
2869
+ inserts.push(await this.signStrandPartyKeyInsert(strand.id, partyKey, ownerKey, signMessage));
2870
+ }
2871
+ if (formationInvite) {
2872
+ const { token, sAppId, ...options } = formationInvite;
2873
+ inserts.push(await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, { ...options, strandId: strand.id }));
2874
+ }
2875
+ // Bare `exec`: the lock is held, and `execWrite` would take it again and self-deadlock.
2876
+ await this.lockedWithRetry(() => this.inTransaction('found strand', async () => {
2877
+ for (const insert of inserts) {
2878
+ await this.db!.exec(insert.sql, insert.params);
2879
+ }
2880
+ }), {}, 'strand-found');
2881
+ log('Founding rows inserted: strand %s', strand.id);
2485
2882
  }
2486
2883
 
2487
2884
  /**
@@ -2590,22 +2987,33 @@ export class ControlDatabase {
2590
2987
  ): Promise<void> {
2591
2988
  this.ensureInitialized();
2592
2989
  log('Inserting strand party key for strand: %s', strandId);
2990
+ const insert = await this.signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage);
2991
+ await this.execWrite(insert.sql, insert.params, 'strand-party-key-insert');
2992
+ log('Strand party key inserted for strand: %s', strandId);
2993
+ }
2593
2994
 
2594
- const peerId = this.config.libp2pNode.peerId.toString();
2595
- const stampId = generateStampId(peerId);
2995
+ /** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
2996
+ private async signStrandPartyKeyInsert(
2997
+ strandId: string,
2998
+ privateKey: string,
2999
+ ownerKey: string,
3000
+ signMessage: ControlSign
3001
+ ): Promise<SignedInsert> {
3002
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2596
3003
 
2597
3004
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
2598
3005
  // Id, PrivateKey, StampId.
2599
3006
  const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
2600
3007
  const signature = await signMessage(message);
2601
3008
 
2602
- await this.execWrite(`
2603
- insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
2604
- with context OwnerKey = ?, Signature = ?
2605
- values (?, ?, ?)
2606
- `, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
2607
-
2608
- log('Strand party key inserted for strand: %s', strandId);
3009
+ return {
3010
+ sql: `
3011
+ insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
3012
+ with context OwnerKey = ?, Signature = ?
3013
+ values (?, ?, ?)
3014
+ `,
3015
+ params: [ownerKey, signature, strandId, privateKey, stampId],
3016
+ };
2609
3017
  }
2610
3018
 
2611
3019
  /**
@@ -2969,32 +3377,44 @@ export class ControlDatabase {
2969
3377
  }
2970
3378
 
2971
3379
  /**
2972
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
2973
- * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
2974
- * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
2975
- * the row is re-emitted as a fresh, broadcasting transaction.
2976
- *
2977
- * This is the write-while-alone re-replication primitive: a membership row that committed
2978
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
2979
- * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
2980
- * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
2981
- * and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
2982
- * owner branch requires them kept: they are the machine's own claim) — so it is safe
2983
- * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
2984
- * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
2985
- * `UpdatedAt` without re-signing would invalidate its self-signature).
2986
- *
2987
- * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
2988
- * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
2989
- * membership snapshot cannot have changed.
2990
- *
2991
- * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
2992
- * that removes the row in between makes the `update` match nothing, and this still returns
2993
- * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
2994
- * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
2995
- * re-add in the same window fails loudly instead, since the signature binds the retired
2996
- * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
2997
- * into the locked body.
3380
+ * Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
3381
+ * (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
3382
+ * `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
3383
+ * {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
3384
+ * transaction, or stands on a different owner's voucher.
3385
+ *
3386
+ * Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
3387
+ * committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
3388
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
3389
+ * the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
3390
+ * stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
3391
+ * (the owner branch requires them kept: they are the machine's own claim) — so it is safe
3392
+ * over a row whose peer has not self-published (`Sig` null), and this method itself skips a
3393
+ * row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
3394
+ * bumping `UpdatedAt` without re-signing would invalidate its self-signature).
3395
+ *
3396
+ * The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
3397
+ * bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
3398
+ * only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
3399
+ * writes whether or not the machine has self-published (the owner branch has no monotonic
3400
+ * rule, so the column simply keeps its value).
3401
+ *
3402
+ * The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
3403
+ * row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
3404
+ * what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
3405
+ * the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
3406
+ * at the moment that publish does. A re-run after losing to it reads the self-signed row and
3407
+ * reports `'self-published'` instead of writing the owner branch over it — which would rewrite
3408
+ * `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
3409
+ * resolvers until the machine's next heartbeat. The decision has to be the body's for that;
3410
+ * a caller's pre-check outside the lock is only a fast path (see below).
3411
+ *
3412
+ * An absent row — none, or its stamp retired, or a stampless row no writer produces — is
3413
+ * `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
3414
+ * writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
3415
+ * ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
3416
+ * in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
3417
+ * which only re-reads the member set.
2998
3418
  *
2999
3419
  * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
3000
3420
  * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
@@ -3017,36 +3437,52 @@ export class ControlDatabase {
3017
3437
  * VouchOwner/VouchSig) rather than inherit this rebinding.
3018
3438
  *
3019
3439
  * @param peerId - the membership row to re-touch.
3020
- * @param updatedAt - the strictly-increasing freshness stamp to write.
3440
+ * @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
3021
3441
  * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
3022
- * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
3023
- * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
3442
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
3443
+ * called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
3444
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}).
3024
3445
  */
3025
3446
  async reauthorizeCadrePeer(
3026
3447
  peerId: string,
3027
- updatedAt: number,
3448
+ mode: CadrePeerRetouchMode,
3028
3449
  ownerKey: string,
3029
3450
  signMessage: ControlSign
3030
- ): Promise<boolean> {
3451
+ ): Promise<CadrePeerRetouchOutcome> {
3031
3452
  this.ensureInitialized();
3032
- const stampId = await this.queryStampId('CadrePeer', peerId);
3033
- if (stampId === null) {
3034
- log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
3035
- return false;
3453
+ const outside = retouchTarget(await this.findCadrePeerRow(peerId, true), mode);
3454
+ if (typeof outside === 'string') {
3455
+ log('reauthorizeCadrePeer(%s): %s for %s (nothing to write)', mode, outside, peerId);
3456
+ return outside;
3036
3457
  }
3037
- const signature = await signMessage(
3038
- buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId])
3039
- );
3040
- await this.mutateCadrePeer('peer-reauthorize', async () => {
3458
+ return await this.mutateCadrePeer('peer-reauthorize', async () => {
3459
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
3460
+ const target = retouchTarget(await this.findCadrePeerRow(peerId, false), mode);
3461
+ if (typeof target === 'string') {
3462
+ log('reauthorizeCadrePeer(%s): %s for %s since the pre-check (nothing to write)', mode, target, peerId);
3463
+ return target;
3464
+ }
3465
+ const signature = await signMessage(
3466
+ buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, target.stampId])
3467
+ );
3041
3468
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
3042
- await this.db!.exec(`
3043
- update CadreControl.CadrePeer
3044
- with context OwnerKey = ?, Signature = ?
3045
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
3046
- where PeerId = ?
3047
- `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
3048
- });
3049
- return true;
3469
+ if (mode === 'rebroadcast') {
3470
+ await this.db!.exec(`
3471
+ update CadreControl.CadrePeer
3472
+ with context OwnerKey = ?, Signature = ?
3473
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
3474
+ where PeerId = ?
3475
+ `, [ownerKey, signature, Math.max(Date.now(), target.updatedAt + 1), ownerKey, signature, peerId]);
3476
+ } else {
3477
+ await this.db!.exec(`
3478
+ update CadreControl.CadrePeer
3479
+ with context OwnerKey = ?, Signature = ?
3480
+ set VouchOwner = ?, VouchSig = ?, VouchUsage = null
3481
+ where PeerId = ?
3482
+ `, [ownerKey, signature, ownerKey, signature, peerId]);
3483
+ }
3484
+ return 'retouched';
3485
+ }, LOST_UPDATE_RETRY_POLICY);
3050
3486
  }
3051
3487
 
3052
3488
  /**
@@ -3068,6 +3504,12 @@ export class ControlDatabase {
3068
3504
  *
3069
3505
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
3070
3506
  * when a row was actually removed.
3507
+ *
3508
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
3509
+ * at the moment of the removal (its first publish lands about a second after it starts), and
3510
+ * the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
3511
+ * and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
3512
+ * binds, so the re-run reads the published row and removes it.
3071
3513
  */
3072
3514
  deleteCadrePeer(
3073
3515
  peerId: string,
@@ -3075,7 +3517,7 @@ export class ControlDatabase {
3075
3517
  signMessage: ControlSign
3076
3518
  ): Promise<boolean> {
3077
3519
  return this.mutateCadrePeer('peer-remove', () =>
3078
- this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
3520
+ this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage), LOST_UPDATE_RETRY_POLICY);
3079
3521
  }
3080
3522
 
3081
3523
  /**
@@ -3178,9 +3620,8 @@ export class ControlDatabase {
3178
3620
  /**
3179
3621
  * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
3180
3622
  * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
3181
- * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
3182
- * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
3183
- * lock and, where one is needed, the transaction.
3623
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
3624
+ * supplies the lock and, where one is needed, the transaction.
3184
3625
  *
3185
3626
  * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
3186
3627
  * than leaning on the column default — the seat-at-zero rule is load-bearing for
@@ -3189,11 +3630,7 @@ export class ControlDatabase {
3189
3630
  * `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
3190
3631
  * equal), so a node that receives the row by replication can tell which owner filed it.
3191
3632
  */
3192
- private async execTombstone(
3193
- ref: RevokedRowRef | typeof REVOCATION_LEDGER_MARKER,
3194
- revocationSignature: string,
3195
- ownerKey: string
3196
- ): Promise<void> {
3633
+ private async execTombstone(ref: RevokedRowRef, revocationSignature: string, ownerKey: string): Promise<void> {
3197
3634
  await this.db!.exec(`
3198
3635
  insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
3199
3636
  with context OwnerKey = ?, Signature = ?
@@ -3239,9 +3676,10 @@ export class ControlDatabase {
3239
3676
  * statement, and without the clause the reap would delete the owner's brand-new row.
3240
3677
  * With it the statement matches nothing and the reap is a silent no-op (the schema
3241
3678
  * branch would also refuse that delete, but as a constraint error thrown into a
3242
- * background sweep — matched-nothing is the better failure mode). Like
3243
- * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
3244
- * harmless — nothing was deleted, and the next sweep re-reads.
3679
+ * background sweep — matched-nothing is the better failure mode). A race lost AFTER the
3680
+ * guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
3681
+ * its lock and reports what it found); harmless — nothing was deleted, and the next sweep
3682
+ * re-reads.
3245
3683
  *
3246
3684
  * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
3247
3685
  * committed tombstone that authorizes the reap, so a reap files no second tombstone.
@@ -3268,7 +3706,7 @@ export class ControlDatabase {
3268
3706
  * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
3269
3707
  *
3270
3708
  * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
3271
- * reconcile pass runs while the node has at least one control connection.
3709
+ * reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
3272
3710
  */
3273
3711
  async reapRevokedRow(table: ReapableTable, rowKey: string, stampId: string): Promise<boolean> {
3274
3712
  this.ensureInitialized();
@@ -3325,8 +3763,8 @@ export class ControlDatabase {
3325
3763
  * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
3326
3764
  * that converged on a removal while holding the removed row keeps that row as inert
3327
3765
  * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
3328
- * control-cohort reconcile pass is that something, gated on the node having at least one
3329
- * control connection (a reap is a write, and a write committed alone is local-only).
3766
+ * control-cohort reconcile pass is that something, gated on a sibling being reachable
3767
+ * (a reap is a write, and a write committed alone is local-only).
3330
3768
  *
3331
3769
  * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
3332
3770
  * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
@@ -3359,12 +3797,11 @@ export class ControlDatabase {
3359
3797
  *
3360
3798
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
3361
3799
  * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
3362
- * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
3363
- * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
3364
- * only while `Revocation` has never been written; the ledger marker
3365
- * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
3800
+ * the common case (a party that has never revoked anyone) a single scan per pass, served
3801
+ * from the `Revocation` membership view
3802
+ * ({@link readMembershipRows}) unless the table changed since the last read.
3366
3803
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
3367
- * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
3804
+ * lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
3368
3805
  * party; if the table ever grows, persist a node-local high-water mark of what has
3369
3806
  * already been reaped instead of re-scanning everything (the same bound the sweep in
3370
3807
  * `CadreNode.drainPendingRevocations` wants).
@@ -3482,78 +3919,6 @@ export class ControlDatabase {
3482
3919
  }, {}, 'revocation-reissue');
3483
3920
  }
3484
3921
 
3485
- /**
3486
- * Owner-signed filing of the singleton `Revocation` ledger marker
3487
- * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
3488
- *
3489
- * Why it exists: while `Revocation` has never been written, this node does not hold its
3490
- * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
3491
- * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
3492
- * reads this table. Once any row exists the block is held and re-checked at most once per
3493
- * read-repair window, like every other populated table. The schema's table comment says
3494
- * why the row can never read as a retirement.
3495
- *
3496
- * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
3497
- * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
3498
- * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
3499
- * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
3500
- * full composite primary key, which is served as a point lookup that can miss an existing
3501
- * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
3502
- *
3503
- * Two owners filing at once: the loser either sees the marker in its guard or is refused
3504
- * on the primary key, and both answer `'already-open'`.
3505
- *
3506
- * The caller decides WHEN. A marker committed while the node is alone is local-only and
3507
- * can fork the collection, so {@link CadreNode} files it only while connected; this
3508
- * method does not look at connectivity.
3509
- *
3510
- * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
3511
- * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
3512
- * private key, returning a base64url signature.
3513
- * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
3514
- * already there.
3515
- */
3516
- async openRevocationLedger(
3517
- ownerKey: string,
3518
- signMessage: ControlSign
3519
- ): Promise<RevocationLedgerOpenResult> {
3520
- this.ensureInitialized();
3521
- const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
3522
- try {
3523
- return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
3524
- if (await this.revocationLedgerFiled()) {
3525
- return 'already-open';
3526
- }
3527
- // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
3528
- await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
3529
- log('Revocation ledger marker filed');
3530
- return 'opened';
3531
- }, {}, 'revocation-ledger-open');
3532
- } catch (error) {
3533
- if (!isRevocationLedgerConflict(error)) {
3534
- throw error;
3535
- }
3536
- log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
3537
- return 'already-open';
3538
- }
3539
- }
3540
-
3541
- /**
3542
- * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
3543
- * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
3544
- * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
3545
- */
3546
- private async revocationLedgerFiled(): Promise<boolean> {
3547
- const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
3548
- const rows = await this.readRows(
3549
- 'select StampId from CadreControl.Revocation where TableName = ?',
3550
- [tableName],
3551
- 'revocation-ledger',
3552
- false
3553
- );
3554
- return rows.some(row => row.StampId === stampId);
3555
- }
3556
-
3557
3922
  /**
3558
3923
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
3559
3924
  *
@@ -3600,11 +3965,28 @@ export class ControlDatabase {
3600
3965
  * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
3601
3966
  * second call replaces the first rather than fanning out, so a stale node can
3602
3967
  * never keep receiving notifications for a database it no longer owns.
3968
+ *
3969
+ * Only this database's own writes notify here, awaited inside the write so the gate is
3970
+ * current before the write returns. A membership change replicated in from another
3971
+ * machine reaches the gate through the subscription `CadreNode.start` takes on the
3972
+ * membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
3973
+ * lands in storage; one storage never hears of waits for the reconcile pass's backstop.
3603
3974
  */
3604
3975
  setMembershipChangeListener(listener: MembershipChangeListener | null): void {
3605
3976
  this.membershipListener = listener;
3606
3977
  }
3607
3978
 
3979
+ /**
3980
+ * Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
3981
+ * made here and changes replicated into this node's storage alike, on the single-flight
3982
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
3983
+ * still live before it drains the write queue.
3984
+ */
3985
+ watchTables(tables: readonly string[], onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
3986
+ this.ensureInitialized();
3987
+ return this.tableWatches.watch(this.db!, tables, onChange, options);
3988
+ }
3989
+
3608
3990
  /**
3609
3991
  * Wire (or clear, with null) the single listener notified after every committed
3610
3992
  * guarded-table delete. Same ownership contract as
@@ -3665,8 +4047,11 @@ export class ControlDatabase {
3665
4047
  * exhaustion.
3666
4048
  *
3667
4049
  * @param reason - Label for the log line only (e.g. `'peer-insert'`).
4050
+ * @param policy - The retry policy, default unless the body has earned a wider one: the three
4051
+ * `CadrePeer` writers whose bodies re-read the row and decide inside pass
4052
+ * `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
3668
4053
  */
3669
- async mutateCadrePeer<T>(reason: string, body: () => Promise<T>): Promise<T> {
4054
+ async mutateCadrePeer<T>(reason: string, body: () => Promise<T>, policy: ControlWriteRetryOptions = {}): Promise<T> {
3670
4055
  this.ensureInitialized();
3671
4056
  return this.lockedWithRetry(async () => {
3672
4057
  this.assertCommitBoundary(reason, 'on entry to');
@@ -3674,7 +4059,7 @@ export class ControlDatabase {
3674
4059
  this.assertCommitBoundary(reason, 'on return from');
3675
4060
  await this.notifyMembershipChanged(reason);
3676
4061
  return result;
3677
- }, {}, reason);
4062
+ }, policy, reason);
3678
4063
  }
3679
4064
 
3680
4065
  /**
@@ -3689,8 +4074,8 @@ export class ControlDatabase {
3689
4074
  * class: every public write method of this class runs its statement(s) under it, and
3690
4075
  * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
3691
4076
  * `Database` directly, so it is covered too. The race is
3692
- * real, not theoretical: the background self-record publish on a control connection
3693
- * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
4077
+ * real, not theoretical: the background self-record publish when a sibling becomes
4078
+ * reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
3694
4079
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
3695
4080
  * assert.
3696
4081
  *
@@ -3714,6 +4099,11 @@ export class ControlDatabase {
3714
4099
  * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
3715
4100
  * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
3716
4101
  * attempt: a retry re-presents the exact signed message the first attempt presented.
4102
+ * A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
4103
+ * its re-run is a re-decision over a row another writer changed, not a re-presentation of
4104
+ * the same write, so its read of the row, its decision and its signature all run in the
4105
+ * body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
4106
+ * under {@link deleteCadrePeer}, {@link removeMachine}).
3717
4107
  */
3718
4108
  async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
3719
4109
  // Chain behind the current tail regardless of how it settled, then park a
@@ -3755,9 +4145,14 @@ export class ControlDatabase {
3755
4145
  * `initialized` is set — so it reaches this method directly rather than through
3756
4146
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
3757
4147
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
3758
- * rather than carving out a lockless path. It is also the ONLY caller that passes a
3759
- * `policy` — everything else takes the default, whose classifier and attempt count are
3760
- * unchanged by that call site existing.
4148
+ * rather than carving out a lockless path.
4149
+ *
4150
+ * Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
4151
+ * call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
4152
+ * row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
4153
+ * {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
4154
+ * policy parameter). Everything else takes the default, whose classifier and attempt count
4155
+ * are unchanged by those call sites existing.
3761
4156
  *
3762
4157
  * `label` names the operation in this loop's debug lines and nothing else
3763
4158
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
@@ -3902,7 +4297,22 @@ export class ControlDatabase {
3902
4297
  ): Promise<void> {
3903
4298
  this.ensureInitialized();
3904
4299
  log('Inserting formation invite: %s', token);
4300
+ const insert = await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options);
4301
+ await this.execWrite(insert.sql, insert.params, 'formation-invite-insert');
4302
+ log('Formation invite inserted: %s', token);
4303
+ }
3905
4304
 
4305
+ /**
4306
+ * The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
4307
+ * {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
4308
+ */
4309
+ private async signFormationInviteInsert(
4310
+ token: string,
4311
+ sAppId: string,
4312
+ ownerKey: string,
4313
+ signMessage: ControlSign,
4314
+ options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string }
4315
+ ): Promise<SignedInsert> {
3906
4316
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
3907
4317
 
3908
4318
  // Build each bound field exactly as the deferred CHECK sees it post-coercion:
@@ -3929,21 +4339,22 @@ export class ControlDatabase {
3929
4339
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
3930
4340
  // signed source-of-truth and the stored value are produced once. StampId is a real,
3931
4341
  // unique column (single-use anti-replay), no longer a context value.
3932
- await this.execWrite(`
3933
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3934
- with context OwnerKey = ?, Signature = ?
3935
- values (?, ?, ?, ?, ?, ?, ?)
3936
- `, [
3937
- ownerKey, signature,
3938
- token, sAppId,
3939
- expiresAtCanonical,
3940
- options.totalUses ?? null,
3941
- options.validationUrl ?? null,
3942
- options.strandId ?? null,
3943
- stampId,
3944
- ], 'formation-invite-insert');
3945
-
3946
- log('Formation invite inserted: %s', token);
4342
+ return {
4343
+ sql: `
4344
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
4345
+ with context OwnerKey = ?, Signature = ?
4346
+ values (?, ?, ?, ?, ?, ?, ?)
4347
+ `,
4348
+ params: [
4349
+ ownerKey, signature,
4350
+ token, sAppId,
4351
+ expiresAtCanonical,
4352
+ options.totalUses ?? null,
4353
+ options.validationUrl ?? null,
4354
+ options.strandId ?? null,
4355
+ stampId,
4356
+ ],
4357
+ };
3947
4358
  }
3948
4359
 
3949
4360
  /**
@@ -4066,14 +4477,13 @@ export class ControlDatabase {
4066
4477
  * Use {@link redeemInvitation} instead when the strand must be created by
4067
4478
  * consent atomically with the usage.
4068
4479
  *
4069
- * The strand's live `StampId` is read first and written onto the usage row:
4480
+ * The strand's live `StampId` is written onto the usage row:
4070
4481
  * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
4071
4482
  * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
4072
- * record authorizes exactly the strand ROW it was recorded against. A missing strand
4073
- * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
4074
- * ordinary "host strand has not converged yet" case is already reported as `missing`
4075
- * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
4076
- * point is a genuine race and deserves a named error, not a silent rollback.
4483
+ * record authorizes exactly the strand ROW it was recorded against. A caller that already
4484
+ * read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
4485
+ * missing strand THROWS {@link MissingHostStrandError} rather than being left to the
4486
+ * deferred `StrandExists` CHECK.
4077
4487
  *
4078
4488
  * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
4079
4489
  * path — the one production actually races on (a bound invite published by `cadre-web` /
@@ -4108,6 +4518,13 @@ export class ControlDatabase {
4108
4518
  * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
4109
4519
  */
4110
4520
  totalUses?: number | null;
4521
+ /**
4522
+ * The host strand's `StampId`, when the caller already read the row
4523
+ * ({@link queryStrandWithStamp}); `undefined` reads it here. A stamp is fixed for the life
4524
+ * of its row, so one read earlier is the stamp `StrandExists` compares, unless the row was
4525
+ * removed (and re-seated) in between, which that CHECK refuses at commit.
4526
+ */
4527
+ strandStampId?: string;
4111
4528
  }): Promise<FormationUsageResult> {
4112
4529
  this.ensureInitialized();
4113
4530
  const {
@@ -4118,7 +4535,7 @@ export class ControlDatabase {
4118
4535
 
4119
4536
  // The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
4120
4537
  // consent record authorizes.
4121
- const strandStampId = await this.queryStrandStampId(strandId);
4538
+ const strandStampId = params.strandStampId ?? await this.queryStrandStampId(strandId);
4122
4539
  if (strandStampId === null) {
4123
4540
  throw new MissingHostStrandError(strandId, token);
4124
4541
  }
@@ -4262,7 +4679,7 @@ export class ControlDatabase {
4262
4679
  * permissive PRE-check that runs ahead of it, and a transiently short read costs each of
4263
4680
  * them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
4264
4681
  * {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
4265
- * generic refusal; `ControlFormationUsageRecorder.isTokenUsed` reports not-used and lets
4682
+ * generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
4266
4683
  * the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
4267
4684
  * holds the stranger-admission door open slightly longer.
4268
4685
  *
@@ -4296,12 +4713,12 @@ export class ControlDatabase {
4296
4713
  /**
4297
4714
  * Is any `FormationInvite` row still redeemable — unexpired AND with usage
4298
4715
  * below its `TotalUses`? A null `ExpiresAt` never expires and a null
4299
- * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
4300
- * per-token semantics (`isTokenValid` / `isTokenUsed`).
4716
+ * `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
4717
+ * (`formation-token.ts`).
4301
4718
  *
4302
4719
  * Answers the control-network connection gate's coarse "does this node expect
4303
4720
  * a stranger?" question, which has no token to ask about. The expiry
4304
- * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
4721
+ * comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
4305
4722
  * invite the formation handler would reject can never hold the gate open.
4306
4723
  *
4307
4724
  * The scan is deliberately not pushed into SQL: nothing else here compares a
@@ -4494,8 +4911,10 @@ export class ControlDatabase {
4494
4911
  * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
4495
4912
  * question, and `CadreInviteUsage.Authorized`'s at redemption.
4496
4913
  *
4497
- * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
4498
- * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4914
+ * A redemption reads the row once, before its write, and carries it into
4915
+ * {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
4916
+ * {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
4917
+ * the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4499
4918
  */
4500
4919
  async queryCadreInvite(key: string, retry = true): Promise<CadreInviteRow | null> {
4501
4920
  this.ensureInitialized();
@@ -4513,23 +4932,16 @@ export class ControlDatabase {
4513
4932
  * {@link queryCadrePeers}' reason.
4514
4933
  */
4515
4934
  async queryCadreInvites(retry = true): Promise<CadreInviteRow[]> {
4516
- this.ensureInitialized();
4517
- const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
4518
- return rows.map(cadreInviteRowOf);
4935
+ return (await this.readMembershipRows(this.cadreInviteView, retry)).map(cadreInviteRowOf);
4519
4936
  }
4520
4937
 
4521
4938
  /**
4522
4939
  * Every `CadreInviteUsage` row this node holds — the redemption records the membership
4523
4940
  * predicate verifies invitation-admitted rows against. Same `retry` contract as
4524
- * {@link queryCadreInvites}.
4941
+ * {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
4525
4942
  */
4526
4943
  async queryCadreInviteUsages(retry = true): Promise<CadreInviteUsageRow[]> {
4527
- this.ensureInitialized();
4528
- const rows = await this.readRows(
4529
- 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
4530
- undefined, 'cadre-invite-usages', retry
4531
- );
4532
- return rows.map(cadreInviteUsageRowOf);
4944
+ return (await this.readMembershipRows(this.cadreInviteUsageView, retry)).map(cadreInviteUsageRowOf);
4533
4945
  }
4534
4946
 
4535
4947
  /**
@@ -4575,6 +4987,9 @@ export class ControlDatabase {
4575
4987
  // counts make that free today; if a long-lived cadre accumulates thousands and inbound
4576
4988
  // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
4577
4989
  // first needs every device they admitted re-vouched, so the chain no longer needs them).
4990
+ // NOTE: this scan reaches storage while queryCadreInvites serves the same rows from the
4991
+ // membership view; if the stranger path's reads ever near the gate's 2 s deadline, walk
4992
+ // queryCadreInvites() here instead.
4578
4993
  const metered: Array<{ key: string; totalUses: number }> = [];
4579
4994
  let unlimitedLive = false;
4580
4995
  for (const row of await this.readRows(
@@ -4612,16 +5027,21 @@ export class ControlDatabase {
4612
5027
 
4613
5028
  /**
4614
5029
  * Is this one cadre invitation still redeemable here — the conditions
4615
- * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
4616
- * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
4617
- * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
4618
- * judged by whichever owner it names as issuer. A permissive pre-check like
4619
- * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
4620
- * what enforce every condition.
4621
- */
4622
- async isCadreInviteLive(invite: CadreInviteRow, nowMs: number = Date.now()): Promise<boolean> {
5030
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
5031
+ * node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
5032
+ * none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
5033
+ * verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
5034
+ * issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
5035
+ * and the redemption's own write are what enforce every condition.
5036
+ *
5037
+ * The use count read here is read again inside the redemption's write lock
5038
+ * ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
5039
+ * pushed the control store before the write, so the holder of a spent invitation is refused
5040
+ * without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
5041
+ */
5042
+ async isCadreInviteLive(invite: CadreInviteRow, held: CadreInviteRow | null, nowMs: number = Date.now()): Promise<boolean> {
4623
5043
  this.ensureInitialized();
4624
- const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
5044
+ const row = held ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
4625
5045
  return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
4626
5046
  }
4627
5047
 
@@ -4794,11 +5214,27 @@ export class ControlDatabase {
4794
5214
  */
4795
5215
  async redeemCadreInvite(params: {
4796
5216
  inviteKey: string;
5217
+ /**
5218
+ * The row this node holds under `inviteKey`, read before the call ({@link queryCadreInvite});
5219
+ * omitted, it is read inside the locked body. Never a bundle's copy: the requester chooses a
5220
+ * copy's `TotalUses` and `IssuerKey`, and the seat check must judge the stored cap. The
5221
+ * schema refuses every update and delete of the row (`NoUpdate`, `NoDelete`), so the earlier
5222
+ * read is the row a locked read would return; withdrawal is a `Revocation` row, which the schema's
5223
+ * `NotRevoked` and `Authorized` checks still read at commit.
5224
+ */
5225
+ held?: CadreInviteRow;
4797
5226
  peerId: string;
4798
5227
  /** The device's ed25519 public key (base64url); `peerId` is its identity multihash. */
4799
5228
  peerKey: string;
4800
5229
  /** The device's dialable addresses, if known; stored comma-joined as `authorizePeer` does. */
4801
5230
  multiaddrs?: string[];
5231
+ /**
5232
+ * The rest of the device's own signed address record over `multiaddrs`, already verified by
5233
+ * the caller (`admittedRecord` in `cadre-invite-protocol.ts`): stored as the row's
5234
+ * `UpdatedAt`, `Sig` and `Profile`, so the row resolves to the device's addresses from its
5235
+ * admission. Omitted, the row is written unsigned, `UpdatedAt` at this machine's clock.
5236
+ */
5237
+ record?: Pick<PeerAddressRecord, 'updatedAt' | 'profile' | 'sig'>;
4802
5238
  /** Single-use nonce minted by the device; both signatures cover it. */
4803
5239
  usageStampId: string;
4804
5240
  /** The holder's signature with the invitation private key over the `'redeem'` digest. */
@@ -4808,13 +5244,13 @@ export class ControlDatabase {
4808
5244
  nowMs?: number;
4809
5245
  }): Promise<CadreInviteRedemptionResult> {
4810
5246
  this.ensureInitialized();
4811
- const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
5247
+ const { inviteKey, peerId, peerKey, record, usageStampId, inviteSig, peerSig } = params;
4812
5248
  const nowMs = params.nowMs ?? Date.now();
4813
5249
  const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
4814
5250
  log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
4815
5251
 
4816
5252
  return this.mutateCadrePeer('cadre-invite-redeem', async () => {
4817
- const invite = await this.queryCadreInvite(inviteKey, false);
5253
+ const invite = params.held ?? await this.queryCadreInvite(inviteKey, false);
4818
5254
  if (invite === null) {
4819
5255
  throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
4820
5256
  }
@@ -4834,10 +5270,11 @@ export class ControlDatabase {
4834
5270
  const nowCanonical = await canonicalDatetime(this.db!, nowMs);
4835
5271
  await this.inTransaction('cadre-invite-redeem', async () => {
4836
5272
  await this.db!.exec(`
4837
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
5273
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig, VouchUsage)
4838
5274
  with context OwnerKey = null, Signature = null
4839
- values (?, ?, ?, ?, null, ?, ?, null, ?)
4840
- `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
5275
+ values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)
5276
+ `, [peerId, peerKey, multiaddr, record?.updatedAt ?? nowMs, record?.sig ?? null, record?.profile ?? null,
5277
+ peerStampId, invite.issuerKey, usageStampId]);
4841
5278
  await this.db!.exec(`
4842
5279
  insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
4843
5280
  with context Now = ?
@@ -4947,7 +5384,9 @@ export class ControlDatabase {
4947
5384
  /**
4948
5385
  * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
4949
5386
  * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
4950
- * away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
5387
+ * away. Read once per redemption and carried into {@link isOwnerGrantLive} and
5388
+ * {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
5389
+ * handed none and reads inside its locked body.
4951
5390
  */
4952
5391
  async queryOwnerGrant(key: string, retry = true): Promise<OwnerGrantRow | null> {
4953
5392
  this.ensureInitialized();
@@ -4963,19 +5402,12 @@ export class ControlDatabase {
4963
5402
  * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
4964
5403
  */
4965
5404
  async queryOwnerGrants(retry = true): Promise<OwnerGrantRow[]> {
4966
- this.ensureInitialized();
4967
- const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
4968
- return rows.map(ownerGrantRowOf);
5405
+ return (await this.readMembershipRows(this.ownerGrantView, retry)).map(ownerGrantRowOf);
4969
5406
  }
4970
5407
 
4971
- /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
5408
+ /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}; both come from their membership views ({@link readMembershipRows}). */
4972
5409
  async queryOwnerGrantUsages(retry = true): Promise<OwnerGrantUsageRow[]> {
4973
- this.ensureInitialized();
4974
- const rows = await this.readRows(
4975
- 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage',
4976
- undefined, 'owner-grant-usages', retry
4977
- );
4978
- return rows.map(ownerGrantUsageRowOf);
5410
+ return (await this.readMembershipRows(this.ownerGrantUsageView, retry)).map(ownerGrantUsageRowOf);
4979
5411
  }
4980
5412
 
4981
5413
  /**
@@ -4989,14 +5421,16 @@ export class ControlDatabase {
4989
5421
 
4990
5422
  /**
4991
5423
  * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
4992
- * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
4993
- * node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
4994
- * when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
4995
- * redemption's own write enforce every condition.
5424
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
5425
+ * this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
5426
+ * `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
5427
+ * permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
5428
+ * condition. Its use count is read again inside the write lock, for
5429
+ * {@link isCadreInviteLive}'s reason.
4996
5430
  */
4997
- async isOwnerGrantLive(grant: OwnerGrantRow, nowMs: number = Date.now()): Promise<boolean> {
5431
+ async isOwnerGrantLive(grant: OwnerGrantRow, held: OwnerGrantRow | null, nowMs: number = Date.now()): Promise<boolean> {
4998
5432
  this.ensureInitialized();
4999
- const row = await this.queryOwnerGrant(grant.key) ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
5433
+ const row = held ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
5000
5434
  return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
5001
5435
  }
5002
5436
 
@@ -5058,6 +5492,8 @@ export class ControlDatabase {
5058
5492
  */
5059
5493
  async redeemOwnerGrant(params: {
5060
5494
  grantKey: string;
5495
+ /** The row this node holds under `grantKey`, read before the call; omitted, it is read inside the locked body. Never a bundle's copy, for {@link redeemCadreInvite}'s reason. */
5496
+ held?: OwnerGrantRow;
5061
5497
  /** The owner key to seat (base64url ed25519): the holder generated it, or already holds it. */
5062
5498
  seatedKey: string;
5063
5499
  /** Single-use nonce minted by the holder; both signatures cover it. */
@@ -5074,7 +5510,7 @@ export class ControlDatabase {
5074
5510
  log('Redeeming owner grant %s for %s', grantKey, seatedKey);
5075
5511
 
5076
5512
  return this.mutateCadrePeer('owner-grant-redeem', async () => {
5077
- const grant = await this.queryOwnerGrant(grantKey, false);
5513
+ const grant = params.held ?? await this.queryOwnerGrant(grantKey, false);
5078
5514
  if (grant === null) {
5079
5515
  throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
5080
5516
  }
@@ -5114,7 +5550,10 @@ export class ControlDatabase {
5114
5550
  /**
5115
5551
  * Close the database and cleanup resources.
5116
5552
  *
5117
- * Drains the local-write chain first: {@link withWriteLock} can park a write behind
5553
+ * Disposes every {@link watchTables} subscription before anything else, so a commit that
5554
+ * lands while the write chain drains schedules no run, and again before dropping the handle.
5555
+ *
5556
+ * Then drains the local-write chain: {@link withWriteLock} can park a write behind
5118
5557
  * others across an await, and a queued closure evaluates `this.db!` only when it
5119
5558
  * finally runs — so nulling the handle out from under it would throw a TypeError on a
5120
5559
  * null handle instead of committing. The tail never rejects (it swallows both
@@ -5125,11 +5564,17 @@ export class ControlDatabase {
5125
5564
  * deadline) if a write can ever hang.
5126
5565
  */
5127
5566
  async close(): Promise<void> {
5567
+ this.tableWatches.disposeAll();
5128
5568
  await this.writeQueue;
5129
5569
  if (this.collectionFactory) {
5130
5570
  await this.collectionFactory.shutdown();
5131
5571
  this.collectionFactory = null;
5132
5572
  }
5573
+ this.txnBridge = null;
5574
+ // Again: a subscription taken during the awaits above would keep its timers past close.
5575
+ this.tableWatches.disposeAll();
5576
+ // Here, not first: a read started during the awaits above must not install either.
5577
+ this.invalidateMembershipViews();
5133
5578
  if (this.db) {
5134
5579
  void this.db.close();
5135
5580
  this.db = null;