@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
@@ -4,16 +4,19 @@ import { Database, FunctionFlags, TEXT_RETURN, createScalarFunction, registerPlu
4
4
  import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
5
5
  import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
6
6
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
7
- import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, controlSchemaFileDeclaration } from './control-schema.js';
7
+ import { createEmptyCollections } from '@serfab/quereus-plugin-sereus';
8
+ import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, CONTROL_SCHEMA_NAME, controlSchemaFileDeclaration } from './control-schema.js';
8
9
  import { refuseIncompatibleControlStore } from './control-schema-version.js';
9
10
  import { canonicalDatetime } from './canonical-datetime.js';
10
11
  import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
11
12
  import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
12
13
  import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
13
14
  import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
14
- import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
15
+ import { retryControlWrite, LOST_UPDATE_RETRY_POLICY, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
15
16
  import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
16
17
  import { chainMessages } from './control-retry.js';
18
+ import { TableWatchRegistry } from './table-change-watch.js';
19
+ import { MembershipView } from './membership-view.js';
17
20
  const log = debug('sereus:cadre:control-db');
18
21
  const timing = debug('sereus:cadre:timing');
19
22
  /**
@@ -42,12 +45,11 @@ export function generateStampId(peerId) {
42
45
  * present locally.
43
46
  *
44
47
  * Consent is bound to the strand's one-off `StampId` (see the consent branch of
45
- * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
46
- * The ordinary "host strand has not converged on this responder yet" case is caught
47
- * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
48
- * reaching here means the row vanished between that check and the write, which is a
49
- * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
50
- * CHECK at commit (which would drop the write with a far less legible error).
48
+ * `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
49
+ * inserting, and throws this when the row is absent rather than leaving it to fail the
50
+ * deferred `StrandExists` CHECK at commit (a far less legible error). The formation
51
+ * responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
52
+ * reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
51
53
  */
52
54
  export class MissingHostStrandError extends Error {
53
55
  constructor(strandId, token) {
@@ -87,16 +89,16 @@ export class FormationAbortedError extends Error {
87
89
  * the write, inside {@link ControlDatabase.redeemInvitation} /
88
90
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
89
91
  * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
90
- * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
91
- * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
92
- * pass — and off nothing else. It exists because
92
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
93
+ * `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
94
+ * manager issues a membership pass — and off nothing else. It exists because
93
95
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
94
96
  * schema's own count-based cap clause, which the manager reports as a retryable
95
97
  * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
96
98
  * Two redemptions of one token racing on the SAME node serialize behind the local write
97
- * queue, so the loser reads the winner's committed row and is refused here by name —
98
- * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
99
- * ran before either redemption wrote anything.
99
+ * queue, so the loser reads the winner's committed row and is refused here by name — the
100
+ * use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
101
+ * read before either redemption wrote anything.
100
102
  */
101
103
  export class InvitationExhaustedError extends Error {
102
104
  constructor(token, usesRecorded, totalUses) {
@@ -382,10 +384,9 @@ const GUARDED_KEY_COLUMN = {
382
384
  const ISSUED_TABLE_REASON = { CadreInvite: 'cadre-invite', OwnerGrant: 'owner-grant' };
383
385
  /**
384
386
  * The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
385
- * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by every
386
- * tombstone writer (a guarded removal, an issued row's withdrawal, the ledger marker) so the
387
- * field order lives in one place; the base64url verifier is `peer-authorization.ts`'s
388
- * `revocationDigest`.
387
+ * binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by both
388
+ * tombstone writers (a guarded removal, an issued row's withdrawal) so the field order lives
389
+ * in one place; the base64url verifier is `peer-authorization.ts`'s `revocationDigest`.
389
390
  */
390
391
  function revocationTombstoneMessage(partyId, ref) {
391
392
  return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
@@ -441,7 +442,8 @@ function keyConflictPattern(table) {
441
442
  * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
442
443
  * landed" (re-read, and no-op when the row matches) apart from every other rejection —
443
444
  * an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
444
- * the same test tells "another machine of this party already published this join" apart.
445
+ * the same test tells "another machine of this party already published this join" apart, and
446
+ * for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
445
447
  *
446
448
  * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
447
449
  * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
@@ -496,28 +498,16 @@ export class PendingJoinChangedError extends Error {
496
498
  }
497
499
  }
498
500
  /**
499
- * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
500
- * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
501
- * block. The schema's table comment says why that matters and why the row can never read
502
- * as a retirement; `RowIsGone` pins this exact triple.
501
+ * A `CadreControl.Revocation` row that retires nothing, which parties founded before
502
+ * `control-collections-created-at-cadre-founding` may hold: an owner filed it so the table
503
+ * had a collection, which genesis now creates. Nothing files it now. `RowIsGone` still admits
504
+ * this exact triple, and {@link ControlDatabase.queryRevocations} skips it.
503
505
  */
504
- export const REVOCATION_LEDGER_MARKER = {
506
+ const REVOCATION_LEDGER_MARKER = {
505
507
  tableName: 'Revocation',
506
508
  rowKey: 'ledger',
507
509
  stampId: 'opened',
508
510
  };
509
- /**
510
- * The rejection a second insert of the ledger marker produces: the optimystic vtab's
511
- * primary-key wording (see {@link keyConflictPattern}), naming both columns of
512
- * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
513
- * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
514
- * has no other unique constraint.
515
- */
516
- const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
517
- /** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
518
- function isRevocationLedgerConflict(error) {
519
- return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
520
- }
521
511
  /** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
522
512
  function errorChainMatches(error, pattern) {
523
513
  return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
@@ -580,7 +570,74 @@ export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey', 'Jo
580
570
  */
581
571
  const REAPABLE_TABLE_SET = new Set(REAPABLE_TABLES);
582
572
  const isReapableTable = (table) => REAPABLE_TABLE_SET.has(table);
583
- /** Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them; callers append their `where`. */
573
+ /**
574
+ * The full-table scans the `Revocation`, `CadrePeer` and `OwnerKey` membership views load
575
+ * ({@link ControlDatabase.readMembershipRows}); the four chain tables' scans sit beside their
576
+ * row mappers below. Specs rig these reads by their text: `from CadreControl.Revocation`, and
577
+ * the `CadrePeer` scan by its `select PeerId, Multiaddr, StampId` prefix, which no other
578
+ * `CadrePeer` statement shares.
579
+ */
580
+ const REVOCATION_SELECT_SQL = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
581
+ const CADRE_PEER_SELECT_SQL = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
582
+ const OWNER_KEY_SELECT_SQL = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
583
+ /** A stored `CadrePeer` row as a {@link CadrePeerRow}. */
584
+ function cadrePeerRowOf(row) {
585
+ return {
586
+ peerId: row.PeerId,
587
+ publicKey: row.PublicKey ?? null,
588
+ multiaddr: row.Multiaddr ?? null,
589
+ updatedAt: row.UpdatedAt ?? 0,
590
+ profile: row.Profile ?? null,
591
+ sig: row.Sig ?? null,
592
+ stampId: row.StampId ?? null,
593
+ vouchOwner: row.VouchOwner ?? null,
594
+ vouchSig: row.VouchSig ?? null,
595
+ vouchUsage: row.VouchUsage ?? null,
596
+ };
597
+ }
598
+ /** A {@link CadrePeerRow} as the address record its machine signed ({@link PeerAddressRecord}); see {@link ControlDatabase.queryPeerRecord} for the coalescing. */
599
+ function peerAddressRecordOf(row) {
600
+ const multiaddr = row.multiaddr ?? '';
601
+ return {
602
+ peerId: row.peerId,
603
+ publicKey: row.publicKey ?? '',
604
+ addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
605
+ updatedAt: row.updatedAt,
606
+ profile: row.profile,
607
+ sig: row.sig ?? '',
608
+ };
609
+ }
610
+ /**
611
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s decision over the row as read: the row to write,
612
+ * or the {@link CadrePeerRetouchOutcome} that says why nothing is written — no readable row (a
613
+ * stampless row counts, since the voucher digest binds the stamp), or a self-published row in
614
+ * `'rebroadcast'` mode. One function so the pre-check outside the lock and the re-check inside it
615
+ * cannot disagree.
616
+ */
617
+ function retouchTarget(row, mode) {
618
+ if (row === null || row.stampId === null) {
619
+ return 'absent';
620
+ }
621
+ if (mode === 'rebroadcast' && row.sig !== null) {
622
+ return 'self-published';
623
+ }
624
+ return { stampId: row.stampId, updatedAt: row.updatedAt };
625
+ }
626
+ /** A stored `Revocation` row as a {@link RevocationRow}; the caller has already skipped {@link REVOCATION_LEDGER_MARKER}. */
627
+ function revocationRowOf(row) {
628
+ return {
629
+ tableName: row.TableName,
630
+ rowKey: row.RowKey,
631
+ stampId: row.StampId,
632
+ reissuedAt: row.ReissuedAt ?? 0,
633
+ signerKey: row.SignerKey,
634
+ signerSig: row.SignerSig,
635
+ };
636
+ }
637
+ /**
638
+ * Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them: the full scan the
639
+ * `CadreInvite` membership view loads, and the base the point reads append their `where` to.
640
+ */
584
641
  const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
585
642
  /**
586
643
  * A stored `CadreInvite` row as a {@link CadreInviteRow}. `ExpiresAt` is kept as the
@@ -598,6 +655,8 @@ function cadreInviteRowOf(row) {
598
655
  stampId: row.StampId,
599
656
  };
600
657
  }
658
+ /** The full scan the `CadreInviteUsage` membership view loads, in the order {@link cadreInviteUsageRowOf} reads it. */
659
+ const CADRE_INVITE_USAGE_SELECT_SQL = 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage';
601
660
  /** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
602
661
  function cadreInviteUsageRowOf(row) {
603
662
  return {
@@ -610,7 +669,7 @@ function cadreInviteUsageRowOf(row) {
610
669
  peerSig: row.PeerSig,
611
670
  };
612
671
  }
613
- /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; callers append their `where`. */
672
+ /** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; as {@link CADRE_INVITE_SELECT_SQL}, the view's scan and the point read's base. */
614
673
  const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
615
674
  /** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
616
675
  function ownerGrantRowOf(row) {
@@ -624,6 +683,8 @@ function ownerGrantRowOf(row) {
624
683
  stampId: row.StampId,
625
684
  };
626
685
  }
686
+ /** The full scan the `OwnerGrantUsage` membership view loads, in the order {@link ownerGrantUsageRowOf} reads it. */
687
+ const OWNER_GRANT_USAGE_SELECT_SQL = 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage';
627
688
  /** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
628
689
  function ownerGrantUsageRowOf(row) {
629
690
  return {
@@ -768,12 +829,33 @@ export class ControlDatabase {
768
829
  constructor(config) {
769
830
  this.db = null;
770
831
  this.collectionFactory = null;
832
+ this.txnBridge = null;
771
833
  this.initialized = false;
772
834
  this.membershipListener = null;
773
835
  this.guardedDeleteListener = null;
774
836
  this.controlWriteAbandonedListener = null;
775
837
  /** Tail of the local-write chain — see {@link withWriteLock}. */
776
838
  this.writeQueue = Promise.resolve();
839
+ /** Subscriptions handed out by {@link watchTables}. */
840
+ this.tableWatches = new TableWatchRegistry();
841
+ /** The cached membership tables; see {@link readMembershipRows}. */
842
+ this.revocationView = this.membershipView(REVOCATION_SELECT_SQL, 'revocations');
843
+ this.cadrePeerView = this.membershipView(CADRE_PEER_SELECT_SQL, 'cadre-peers');
844
+ this.ownerKeyView = this.membershipView(OWNER_KEY_SELECT_SQL, 'owner-keys');
845
+ this.cadreInviteView = this.membershipView(CADRE_INVITE_SELECT_SQL, 'cadre-invites');
846
+ this.cadreInviteUsageView = this.membershipView(CADRE_INVITE_USAGE_SELECT_SQL, 'cadre-invite-usages');
847
+ this.ownerGrantView = this.membershipView(OWNER_GRANT_SELECT_SQL, 'owner-grants');
848
+ this.ownerGrantUsageView = this.membershipView(OWNER_GRANT_USAGE_SELECT_SQL, 'owner-grant-usages');
849
+ /** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
850
+ this.membershipViews = new Map([
851
+ ['CadreControl.Revocation', this.revocationView],
852
+ ['CadreControl.CadrePeer', this.cadrePeerView],
853
+ ['CadreControl.OwnerKey', this.ownerKeyView],
854
+ ['CadreControl.CadreInvite', this.cadreInviteView],
855
+ ['CadreControl.CadreInviteUsage', this.cadreInviteUsageView],
856
+ ['CadreControl.OwnerGrant', this.ownerGrantView],
857
+ ['CadreControl.OwnerGrantUsage', this.ownerGrantUsageView],
858
+ ]);
777
859
  /**
778
860
  * Locked write bodies currently running (see {@link runWriteBody}); what
779
861
  * {@link readRowsOnce} consults to route an unlocked read around a write that has
@@ -809,6 +891,7 @@ export class ControlDatabase {
809
891
  return;
810
892
  }
811
893
  log('Initializing ControlDatabase for party: %s', this.config.partyId);
894
+ this.invalidateMembershipViews();
812
895
  // Create database instance
813
896
  this.db = new Database();
814
897
  // Register crypto plugin (provides digest, sign, verify functions)
@@ -836,6 +919,7 @@ export class ControlDatabase {
836
919
  // Before hydrate: a warm restart parses the persisted CHECK text, which calls party_id().
837
920
  registerPartyIdFunction(this.db, this.config.partyId);
838
921
  this.collectionFactory = pluginResult.collectionFactory;
922
+ this.txnBridge = pluginResult.txnBridge;
839
923
  // Inject the libp2p node into the collection factory
840
924
  t0 = performance.now();
841
925
  this.collectionFactory.registerLibp2pNode(networkName, this.config.libp2pNode, this.config.coordinatedRepo);
@@ -882,9 +966,45 @@ export class ControlDatabase {
882
966
  t0 = performance.now();
883
967
  await this.loadSchema();
884
968
  timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
969
+ this.watchMembershipViews();
885
970
  this.initialized = true;
886
971
  log('ControlDatabase initialized successfully');
887
972
  }
973
+ /**
974
+ * Drop a membership view whenever its table changes: a commit made here, or a change this
975
+ * node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
976
+ * does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
977
+ * subscription cannot hear. {@link close} disposes it with the rest.
978
+ */
979
+ watchMembershipViews() {
980
+ this.tableWatches.watch(this.db, [...this.membershipViews.keys()], () => Promise.resolve(), {
981
+ label: 'membership-view',
982
+ onInvalidate: (table) => this.membershipViews.get(table)?.invalidate(),
983
+ });
984
+ }
985
+ /**
986
+ * {@link watchTables} on the membership tables: the seven the membership views copy
987
+ * (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
988
+ * `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
989
+ * read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
990
+ * reads can depend on and no list of them lives outside this class.
991
+ */
992
+ watchMembershipTables(onChange, options) {
993
+ return this.watchTables([...this.membershipViews.keys()], onChange, options);
994
+ }
995
+ /**
996
+ * Empty every membership view, so the next read of each loads afresh and no load in
997
+ * flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
998
+ * a sibling becomes reachable (its identify on a new connection): a machine that was offline
999
+ * while that sibling committed learns of the commit only when it next reads the block from
1000
+ * storage, and dropping the views makes the pass that starts then read now rather than after
1001
+ * `MEMBERSHIP_VIEW_MAX_AGE_MS`.
1002
+ */
1003
+ invalidateMembershipViews() {
1004
+ for (const view of this.membershipViews.values()) {
1005
+ view.invalidate();
1006
+ }
1007
+ }
888
1008
  async loadSchema() {
889
1009
  let declaration;
890
1010
  if (this.config.schemaPath) {
@@ -897,10 +1017,12 @@ export class ControlDatabase {
897
1017
  }
898
1018
  // Declaring runs no DDL; it only records the declaration the check and the apply both read.
899
1019
  await this.db.exec(declaration);
900
- // A warm store this build cannot apply to without losing data is refused HERE, before the
901
- // apply runs anything: the optimystic module refuses most changes to an existing table
902
- // part-way through an apply, and a dropped table takes its rows. Migrating a store in place
903
- // (control-store-migrates-on-start) widens this check to let catalog-only changes through.
1020
+ // The apply below is also the MIGRATION of a warm store written under an older control
1021
+ // schema version: its diff runs each version's changes in order, every one applied to a
1022
+ // table's catalog record without a row rewritten, and ends by recording this build's version
1023
+ // on the store. A store the apply must not touch is refused HERE, before it runs anything: one
1024
+ // a newer release migrated (this machine is the one to update), one from before migrations,
1025
+ // or one whose diff would rewrite or discard rows (`control-schema-version.ts`).
904
1026
  await refuseIncompatibleControlStore(this.db, this.config.partyId);
905
1027
  await this.applyControlSchema();
906
1028
  log('Schema loaded and executed');
@@ -943,6 +1065,20 @@ export class ControlDatabase {
943
1065
  // argument is a statement about the pair, not about Quereus alone — pinned by
944
1066
  // `test/control-schema-apply-unwind.spec.ts`.
945
1067
  //
1068
+ // The same argument covers the statements a MIGRATION runs (`control-schema-version.ts`'s
1069
+ // `catalog-only` kinds, from Optimystic 1.16.0): the plugin's `alterTable` arms (add column,
1070
+ // drop column, add and drop CHECK) and the tag change write the table's catalog record under
1071
+ // the batch's per-statement checkpoint, so a step that throws withdraws its own write, and a
1072
+ // batch whose commit fails is discarded whole and the tables it wrote re-persist on their next
1073
+ // touch. A change the record already holds — another machine migrated first — is adopted with
1074
+ // nothing written, and an undo of an adopted change touches this machine's catalog only. The
1075
+ // one arm Quereus marks irreversible is `drop column`: once it has run, a later failure
1076
+ // unwinds nothing (the apply reports the schema as partially migrated), the plugin's
1077
+ // `endSchemaBatch` commits the steps that landed — or, when that commit fails, marks their
1078
+ // tables to re-persist on next touch — and the retry's diff runs only the steps still
1079
+ // missing. No row is lost either way: rows keep a dropped column's field until they are
1080
+ // rewritten, and nothing reads it.
1081
+ //
946
1082
  // Only the "cohort did not answer, nothing committed" class is retried; the classifier
947
1083
  // vetoes indeterminate commits and does not match `Missing block`, which is a durable
948
1084
  // convergence fault a retry cannot heal (tracked separately) and must keep propagating.
@@ -1034,8 +1170,9 @@ export class ControlDatabase {
1034
1170
  * statement to its transaction opening.
1035
1171
  */
1036
1172
  async readRowsOnce(sql, params, underWriteLock) {
1173
+ const committed = this.writeInFlight(underWriteLock);
1037
1174
  let iterator;
1038
- if (this.writeInFlight(underWriteLock)) {
1175
+ if (committed) {
1039
1176
  log('read-eval: a write is in flight (%s), asking for a committed read: %s', this.db.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
1040
1177
  iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
1041
1178
  }
@@ -1046,7 +1183,7 @@ export class ControlDatabase {
1046
1183
  for await (const row of iterator) {
1047
1184
  rows.push(row);
1048
1185
  }
1049
- return rows;
1186
+ return { rows, committed };
1050
1187
  }
1051
1188
  /**
1052
1189
  * Whether a control read should ask for a committed read — see {@link readRowsOnce}.
@@ -1104,7 +1241,15 @@ export class ControlDatabase {
1104
1241
  * held, and its membership read is opted out at {@link queryCadrePeers}. If another
1105
1242
  * under-lock callback seam is ever added, audit its read graph the same way.
1106
1243
  */
1107
- readRows(sql, params, label, retry = true) {
1244
+ async readRows(sql, params, label, retry = true) {
1245
+ return (await this.readRowsRouted(sql, params, label, retry)).rows;
1246
+ }
1247
+ /**
1248
+ * {@link readRows}, also saying whether the attempt that answered took the committed-read
1249
+ * path — which a {@link MembershipView} load needs, since it may not keep such rows (see
1250
+ * {@link membershipView}).
1251
+ */
1252
+ readRowsRouted(sql, params, label, retry) {
1108
1253
  // `retry: false` marks the reads that may run inside a locked write body, which is
1109
1254
  // also what readRowsOnce's committed-read routing must know (see writeInFlight).
1110
1255
  // NOTE: the membership-gate refresh passes it on EVERY trigger, including the
@@ -1119,6 +1264,44 @@ export class ControlDatabase {
1119
1264
  // it changes log attribution only, never behaviour (same layering as lockedWithRetry).
1120
1265
  return retryControlRead(() => this.readRowsOnce(sql, params, false), { ...this.controlReadRetryPacing, label });
1121
1266
  }
1267
+ /**
1268
+ * A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
1269
+ * funnel. Rows a committed read served are never kept: that read may initialize an untouched
1270
+ * table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
1271
+ * on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
1272
+ * event would ever drop them.
1273
+ */
1274
+ membershipView(sql, label) {
1275
+ return new MembershipView(async (retry) => {
1276
+ const { rows, committed } = await this.readRowsRouted(sql, undefined, label, retry);
1277
+ return { rows, installable: !committed };
1278
+ });
1279
+ }
1280
+ /**
1281
+ * A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
1282
+ * and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
1283
+ * `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
1284
+ * membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
1285
+ * Callers build fresh result objects from them and never mutate them.
1286
+ *
1287
+ * Reads the table directly instead when a caller under the write lock (`retry: false`) has an
1288
+ * explicit transaction open: such a read sees the transaction's own uncommitted rows, which
1289
+ * the view does not hold, and a rollback fires no change event, so keeping them would cache
1290
+ * rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
1291
+ * this keeps one added later, or a spec's raw-handle transaction, correct.
1292
+ *
1293
+ * Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
1294
+ * schema's CHECK constraints, which read inside the committing transaction, the redemption's
1295
+ * one read of the held credential row ({@link queryCadreInvite},
1296
+ * {@link queryOwnerGrant}), and the indexed usage counts.
1297
+ */
1298
+ readMembershipRows(view, retry) {
1299
+ this.ensureInitialized();
1300
+ if (!retry && !this.db.getAutocommit()) {
1301
+ return view.readThrough(retry);
1302
+ }
1303
+ return view.read(retry);
1304
+ }
1122
1305
  /**
1123
1306
  * Query all strands from the control database
1124
1307
  */
@@ -1138,6 +1321,16 @@ export class ControlDatabase {
1138
1321
  const rows = await this.readRows(`select ${STRAND_COLUMNS} from CadreControl.Strand where Id = ?`, [strandId], 'strand');
1139
1322
  return rows.length === 0 ? null : strandRow(rows[0]);
1140
1323
  }
1324
+ /**
1325
+ * {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
1326
+ * resolves a bound invite's host strand with it and later records consent against that
1327
+ * stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
1328
+ */
1329
+ async queryStrandWithStamp(strandId) {
1330
+ this.ensureInitialized();
1331
+ const rows = await this.readRows(`select ${STRAND_COLUMNS}, StampId from CadreControl.Strand where Id = ?`, [strandId], 'strand-with-stamp');
1332
+ return rows.length === 0 ? null : { ...strandRow(rows[0]), StampId: rows[0].StampId };
1333
+ }
1141
1334
  /**
1142
1335
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
1143
1336
  * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
@@ -1216,19 +1409,29 @@ export class ControlDatabase {
1216
1409
  *
1217
1410
  * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
1218
1411
  * insert is refused by the schema whenever any physical row exists, so this must see one.
1412
+ * Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
1413
+ * physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
1414
+ * one storage read.
1219
1415
  */
1220
1416
  async hasOwnerKey() {
1221
1417
  this.ensureInitialized();
1222
- for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
1223
- return row.Count > 0;
1224
- }
1225
- return false;
1418
+ return (await this.readMembershipRows(this.ownerKeyView, true)).length > 0;
1226
1419
  }
1227
1420
  /**
1228
1421
  * Idempotent genesis: insert `key` as the founding owner key only when
1229
1422
  * the party has none yet. Returns true if it inserted, false if an owner
1230
1423
  * key already existed (so a repeat `--owner` start is a no-op).
1231
1424
  *
1425
+ * Before the insert, gives every control table its empty collection
1426
+ * ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
1427
+ * forks if another machine creates the same collection independently; before genesis the
1428
+ * party has no owner, so no other machine can have been admitted and none can hold any of
1429
+ * its collections. The order makes the step repeatable: a crash between it and the insert
1430
+ * leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
1431
+ * collections still missing, while a founder restart finds the row and commits nothing, so
1432
+ * it can never write an empty second history beside a table another machine has since
1433
+ * written. A failure propagates with nothing inserted.
1434
+ *
1232
1435
  * @param label - the user's name for the key ({@link insertOwnerKey}).
1233
1436
  */
1234
1437
  async ensureOwnerKey(key, label) {
@@ -1238,9 +1441,40 @@ export class ControlDatabase {
1238
1441
  log('Owner key already present; skipping genesis insert');
1239
1442
  return false;
1240
1443
  }
1444
+ // Two locked steps one after the other: the lock is not re-entrant, and the insert takes it itself.
1445
+ await this.commitEmptyControlCollections();
1241
1446
  await this.insertOwnerKey(trimmed, label);
1242
1447
  return true;
1243
1448
  }
1449
+ /**
1450
+ * Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
1451
+ * tree that has none, so a read of a table nobody has written is answered from this machine's
1452
+ * storage instead of asking the block's cohort to prove the collection absent — a round trip
1453
+ * per read on a party of several machines, and an error or a wait while they are offline.
1454
+ * Joining machines get these collections the ordinary way, by reading them from the cohort.
1455
+ *
1456
+ * Under the write lock: the helper commits whatever is staged in those collections outside any
1457
+ * SQL transaction, which is sound only while nothing else writes.
1458
+ *
1459
+ * Parties founded before this step, and tables a later control schema version adds, have no
1460
+ * such collections, and nothing creates them later: the start-time schema apply runs on every
1461
+ * machine, possibly alone, where committing one would be exactly the fork genesis avoids.
1462
+ *
1463
+ * NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
1464
+ * 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
1465
+ * transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
1466
+ * 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
1467
+ * node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
1468
+ * geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
1469
+ * (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
1470
+ * `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
1471
+ * founding would use too; do not build a private batching path.
1472
+ */
1473
+ async commitEmptyControlCollections() {
1474
+ const t0 = performance.now();
1475
+ const committed = await this.withWriteLock(() => createEmptyCollections(this.db, this.txnBridge, [CONTROL_SCHEMA_NAME]));
1476
+ log('Committed %d empty control collections in %dms', committed, Math.round(performance.now() - t0));
1477
+ }
1244
1478
  /**
1245
1479
  * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
1246
1480
  *
@@ -1281,16 +1515,14 @@ export class ControlDatabase {
1281
1515
  *
1282
1516
  * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
1283
1517
  * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
1284
- * signers); omitted, they are read here. Before the ledger marker is filed every
1285
- * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
1286
- * membership refresh at one such read for the anchor.
1518
+ * signers); omitted, they are read here. Both come from the same `Revocation` view
1519
+ * ({@link readMembershipRows}), so passing the set saves only the filtering.
1287
1520
  */
1288
1521
  async queryOwnerKeyRows(retry = true, retired) {
1289
1522
  this.ensureInitialized();
1290
1523
  const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
1291
1524
  const rows = [];
1292
- const sql = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
1293
- for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
1525
+ for (const row of await this.readMembershipRows(this.ownerKeyView, retry)) {
1294
1526
  const stampId = row.StampId ?? null;
1295
1527
  if (stampId === null) {
1296
1528
  log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
@@ -1322,12 +1554,8 @@ export class ControlDatabase {
1322
1554
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
1323
1555
  * that seam to model the replicated live-row-plus-tombstone merge state.
1324
1556
  *
1325
- * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
1326
- * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
1327
- * while `Revocation` has never been written every call consults the cohort about it, and
1328
- * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
1329
- * the exclusion into one statement would not help (it reads the same block), and caching
1330
- * the set would hide a tombstone arriving by replication.
1557
+ * Both tables come from their membership views ({@link readMembershipRows}), so a call after
1558
+ * the first costs no storage read until one of the two tables changes.
1331
1559
  *
1332
1560
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
1333
1561
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -1350,27 +1578,12 @@ export class ControlDatabase {
1350
1578
  this.ensureInitialized();
1351
1579
  const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1352
1580
  const rows = [];
1353
- // The proof columns lead and the record columns follow, so this scan keeps a prefix no
1354
- // other `CadrePeer` statement shares (`queryPeerRecord` starts `PeerId, PublicKey`): the
1355
- // read-retry spec rigs the membership scan by that prefix.
1356
- const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
1357
- for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
1358
- const stampId = row.StampId ?? null;
1359
- if (stampId !== null && revoked.has(stampId)) {
1581
+ for (const row of await this.readMembershipRows(this.cadrePeerView, retry)) {
1582
+ const peer = cadrePeerRowOf(row);
1583
+ if (peer.stampId !== null && revoked.has(peer.stampId)) {
1360
1584
  continue;
1361
1585
  }
1362
- rows.push({
1363
- peerId: row.PeerId,
1364
- publicKey: row.PublicKey ?? null,
1365
- multiaddr: row.Multiaddr ?? null,
1366
- updatedAt: row.UpdatedAt ?? 0,
1367
- profile: row.Profile ?? null,
1368
- sig: row.Sig ?? null,
1369
- stampId,
1370
- vouchOwner: row.VouchOwner ?? null,
1371
- vouchSig: row.VouchSig ?? null,
1372
- vouchUsage: row.VouchUsage ?? null,
1373
- });
1586
+ rows.push(peer);
1374
1587
  }
1375
1588
  return rows;
1376
1589
  }
@@ -1424,10 +1637,11 @@ export class ControlDatabase {
1424
1637
  * guard. A read retrying there would sleep its backoff HOLDING the write lock
1425
1638
  * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
1426
1639
  * the write funnel already re-runs the body's reads when it re-runs the body. The
1427
- * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
1428
- * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
1429
- * default retried path. Explicit per call rather than an ambient flag: unlocked reads
1430
- * run concurrently with a locked body, so `this`-state cannot tell the two apart.
1640
+ * unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
1641
+ * read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
1642
+ * rather than an ambient flag: unlocked reads run concurrently with a locked body, so
1643
+ * `this`-state cannot tell the two apart. The same flag threads through
1644
+ * {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
1431
1645
  */
1432
1646
  async queryStampId(table, keyValue, retry = true) {
1433
1647
  this.ensureInitialized();
@@ -1440,8 +1654,8 @@ export class ControlDatabase {
1440
1654
  /**
1441
1655
  * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
1442
1656
  * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
1443
- * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
1444
- * straight to {@link queryStampId}.
1657
+ * `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
1658
+ * the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
1445
1659
  */
1446
1660
  queryCadrePeerStampId(peerId) {
1447
1661
  return this.queryStampId('CadrePeer', peerId);
@@ -1496,18 +1710,17 @@ export class ControlDatabase {
1496
1710
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
1497
1711
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
1498
1712
  *
1499
- * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
1500
- * `Revocation` has never been written, this node does not hold its block, and Optimystic
1501
- * consults a block's cohort on every read of a block it does not hold: 1 consult per
1502
- * call (measured), each a round trip to every other member on a multi-machine party. The
1503
- * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
1504
- * at most once per read-repair window (10 s). Do not cache the set instead: a cache
1505
- * cannot see a tombstone arriving by replication, so it would delay a revocation. The
1506
- * row count only grows (append-only) but stays small while removals are rare; revisit if
1713
+ * Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
1714
+ * every change to the table, local or replicated into this node's storage, so a tombstone
1715
+ * arriving by replication is seen on the next call. The cost that matters is the storage
1716
+ * layer's, paid by a cold view's load: while `Revocation` has never been written, this node
1717
+ * does not hold its block, and Optimystic consults a block's cohort on every read of a block
1718
+ * it does not hold, each a round trip to every other member on a multi-machine party. The row
1719
+ * count only grows (append-only) but stays small while removals are rare; revisit if
1507
1720
  * removals become routine and the scan itself shows up in a profile.
1508
1721
  *
1509
1722
  * An isolated node that has never received the `Revocation` block reads it as holding no
1510
- * revocations instead of throwing — see {@link readRevokedStampRows}.
1723
+ * revocations instead of throwing — see {@link readRevocationRowsOrNone}.
1511
1724
  *
1512
1725
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
1513
1726
  * under the write lock — see that method's note.
@@ -1515,21 +1728,25 @@ export class ControlDatabase {
1515
1728
  async queryRevokedStamps(tableName, retry = true) {
1516
1729
  this.ensureInitialized();
1517
1730
  const stamps = new Set();
1518
- for (const row of await this.readRevokedStampRows(tableName, retry)) {
1519
- stamps.add(row.StampId);
1731
+ for (const row of await this.readRevocationRowsOrNone(retry)) {
1732
+ if (row.TableName === tableName) {
1733
+ stamps.add(row.StampId);
1734
+ }
1520
1735
  }
1521
1736
  return stamps;
1522
1737
  }
1523
1738
  /**
1524
- * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
1525
- * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1739
+ * The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
1740
+ * load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1526
1741
  * the block and could ask no other cohort member about it. Every other failure rethrows.
1527
1742
  * The retry in {@link readRows} runs first, so a node whose connectivity returns within
1528
- * the read budget still gets the real answer.
1743
+ * the read budget still gets the real answer. The empty answer is never installed in the
1744
+ * view (a failed load keeps nothing), so the next call asks again.
1529
1745
  *
1530
1746
  * Without this, a machine cut off from every other machine in its party before it ever
1531
- * received the block — the block does not exist anywhere until the owner files the
1532
- * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
1747
+ * received the block — a joining machine gets it only by reading it from the cohort, and
1748
+ * on a party founded before genesis created every control collection it exists nowhere
1749
+ * until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
1533
1750
  * answer ANY membership, peer-record or device-token lookup, although it holds those
1534
1751
  * rows locally.
1535
1752
  *
@@ -1548,20 +1765,20 @@ export class ControlDatabase {
1548
1765
  *
1549
1766
  * NOTE: an isolated node pays the whole read retry (up to
1550
1767
  * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
1551
- * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
1552
- * node's lookups are seen exceeding the admission gate's 2 s deadline
1553
- * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
1554
- * fails `cohort-unreachable`.
1768
+ * answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
1769
+ * load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
1770
+ * admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
1771
+ * read when the first attempt fails `cohort-unreachable`.
1555
1772
  */
1556
- async readRevokedStampRows(tableName, retry) {
1773
+ async readRevocationRowsOrNone(retry) {
1557
1774
  try {
1558
- return await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry);
1775
+ return await this.readMembershipRows(this.revocationView, retry);
1559
1776
  }
1560
1777
  catch (error) {
1561
1778
  if (!isCohortUnreachableRead(error)) {
1562
1779
  throw error;
1563
1780
  }
1564
- log('revoked-stamps(%s): no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', tableName, error);
1781
+ log('revoked-stamps: no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', error);
1565
1782
  return [];
1566
1783
  }
1567
1784
  }
@@ -1573,10 +1790,10 @@ export class ControlDatabase {
1573
1790
  * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
1574
1791
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1575
1792
  *
1576
- * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1577
- * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1578
- * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1579
- * admits no other row under `'Revocation'`.
1793
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
1794
+ * retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
1795
+ * `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
1796
+ * TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
1580
1797
  *
1581
1798
  * `retry: false` from a caller that reads under the write lock, for the reason on
1582
1799
  * {@link queryCadrePeers}.
@@ -1584,24 +1801,16 @@ export class ControlDatabase {
1584
1801
  async queryRevocations(retry = true) {
1585
1802
  this.ensureInitialized();
1586
1803
  const rows = [];
1587
- const sql = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
1588
- for (const row of await this.readRows(sql, undefined, 'revocations', retry)) {
1589
- if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1590
- continue;
1804
+ for (const row of await this.readMembershipRows(this.revocationView, retry)) {
1805
+ if (row.TableName !== REVOCATION_LEDGER_MARKER.tableName) {
1806
+ rows.push(revocationRowOf(row));
1591
1807
  }
1592
- rows.push({
1593
- tableName: row.TableName,
1594
- rowKey: row.RowKey,
1595
- stampId: row.StampId,
1596
- reissuedAt: row.ReissuedAt ?? 0,
1597
- signerKey: row.SignerKey,
1598
- signerSig: row.SignerSig,
1599
- });
1600
1808
  }
1601
1809
  return rows;
1602
1810
  }
1603
1811
  /**
1604
- * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
1812
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
1813
+ * `CadrePeer` membership view ({@link readMembershipRows}).
1605
1814
  *
1606
1815
  * Returns null when no row exists, or when the row's `StampId` is retired in
1607
1816
  * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
@@ -1617,29 +1826,34 @@ export class ControlDatabase {
1617
1826
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
1618
1827
  * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
1619
1828
  * the publisher signed.
1829
+ *
1830
+ * `retry: false` from the one caller that reads under the write lock,
1831
+ * {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
1832
+ */
1833
+ async queryPeerRecord(peerId, retry = true) {
1834
+ const row = await this.findCadrePeerRow(peerId, retry);
1835
+ return row === null ? null : peerAddressRecordOf(row);
1836
+ }
1837
+ /**
1838
+ * One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
1839
+ * or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
1840
+ * and the two writers that decide over the row inside their locked body
1841
+ * ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
1842
+ * {@link queryCadrePeers}, and the same `retry` contract.
1620
1843
  */
1621
- async queryPeerRecord(peerId) {
1844
+ async findCadrePeerRow(peerId, retry) {
1622
1845
  this.ensureInitialized();
1623
- const revoked = await this.queryRevokedStamps('CadrePeer');
1624
- for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Profile, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
1625
- const stampId = row.StampId ?? null;
1626
- if (stampId !== null && revoked.has(stampId)) {
1627
- return null;
1628
- }
1629
- const multiaddr = row.Multiaddr ?? '';
1630
- return {
1631
- peerId: row.PeerId,
1632
- publicKey: row.PublicKey ?? '',
1633
- addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
1634
- updatedAt: row.UpdatedAt ?? 0,
1635
- profile: row.Profile ?? null,
1636
- sig: row.Sig ?? '',
1637
- };
1846
+ const revoked = await this.queryRevokedStamps('CadrePeer', retry);
1847
+ const row = (await this.readMembershipRows(this.cadrePeerView, retry)).find((candidate) => candidate.PeerId === peerId);
1848
+ if (row === undefined) {
1849
+ return null;
1638
1850
  }
1639
- return null;
1851
+ const peer = cadrePeerRowOf(row);
1852
+ return peer.stampId !== null && revoked.has(peer.stampId) ? null : peer;
1640
1853
  }
1641
1854
  /**
1642
- * Apply a peer's own self-signed address-record update to an existing row.
1855
+ * Apply a peer's own self-signed address-record update to its existing row: read the row
1856
+ * under the write lock, have `sign` build the record over it, and write that record.
1643
1857
  *
1644
1858
  * Authorization is carried entirely by the record: the `Sig` column (verified
1645
1859
  * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
@@ -1648,6 +1862,21 @@ export class ControlDatabase {
1648
1862
  * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
1649
1863
  * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
1650
1864
  *
1865
+ * The read, the decision and the signature all run INSIDE the locked body, which is what
1866
+ * lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
1867
+ * re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
1868
+ * When this write loses that race the commit is refused as a lost update and re-run, and the
1869
+ * re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
1870
+ * signed before the race could no longer exceed — and signs past it. A record signed outside
1871
+ * the body would be re-presented unchanged and refused again, this time by the schema's
1872
+ * monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
1873
+ * (see {@link queryStampId}'s NOTE).
1874
+ *
1875
+ * Returns the record actually written, so the caller's bookkeeping describes the row rather
1876
+ * than the first record it signed; null, and nothing written, when no readable row exists:
1877
+ * the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
1878
+ * as absent every heartbeat, and must not refresh it).
1879
+ *
1651
1880
  * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
1652
1881
  * is not: it only ever touches THIS node's own row (sole caller
1653
1882
  * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
@@ -1660,17 +1889,43 @@ export class ControlDatabase {
1660
1889
  * row, and a self insert happens once at startup, so the wasted refresh is a single read
1661
1890
  * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
1662
1891
  * path is worth exempting.
1892
+ *
1893
+ * @param peerId - the row to refresh: this node's own.
1894
+ * @param sign - builds the signed record over the row as read; called once per attempt.
1663
1895
  */
1664
- async updateSelfPeerRecord(record) {
1896
+ async updateSelfPeerRecord(peerId, sign) {
1665
1897
  this.ensureInitialized();
1666
- const multiaddr = record.addrs.join(',');
1667
- await this.execWrite(`
1668
- update CadreControl.CadrePeer
1669
- with context OwnerKey = null, Signature = ?
1670
- set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
1671
- where PeerId = ?
1672
- `, [record.sig, multiaddr, record.updatedAt, record.profile, record.sig, record.peerId], 'self-record-update');
1673
- log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
1898
+ const written = await this.lockedWithRetry(async () => {
1899
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
1900
+ // NOTE: this read of the Revocation and CadrePeer views is charged to the retry budget,
1901
+ // so a cold view load that waits out a per-peer read deadline leaves the attempt no room
1902
+ // for its one retry (the 14.7 s abandonment the policy's doc cites). The caller's read
1903
+ // outside the funnel warms the views, so this is a cache hit except when a table change
1904
+ // between the two reads dropped them — the racing case itself. If that residual ever
1905
+ // matters, measure which phase of the losing attempt pays the deadline first; the
1906
+ // retired-stamp check cannot simply move outside, since `publishSelfRecord`'s insert
1907
+ // path reads a revoked node's row as absent and relies on this body to skip it.
1908
+ const current = await this.queryPeerRecord(peerId, false);
1909
+ if (current === null) {
1910
+ return null;
1911
+ }
1912
+ const record = sign(current);
1913
+ // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1914
+ await this.db.exec(`
1915
+ update CadreControl.CadrePeer
1916
+ with context OwnerKey = null, Signature = ?
1917
+ set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
1918
+ where PeerId = ?
1919
+ `, [record.sig, record.addrs.join(','), record.updatedAt, record.profile, record.sig, peerId]);
1920
+ return record;
1921
+ }, LOST_UPDATE_RETRY_POLICY, 'self-record-update');
1922
+ if (written === null) {
1923
+ log('Self peer record not updated: no readable CadrePeer row for %s (absent, or its stamp retired)', peerId);
1924
+ }
1925
+ else {
1926
+ log('Self peer record updated: %s (updatedAt=%d)', peerId, written.updatedAt);
1927
+ }
1928
+ return written;
1674
1929
  }
1675
1930
  /**
1676
1931
  * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
@@ -1903,6 +2158,13 @@ export class ControlDatabase {
1903
2158
  * body, and a row whose stamp is already retired (held without its delete) is skipped rather
1904
2159
  * than tombstoned twice. Nothing to remove writes nothing and returns all false.
1905
2160
  *
2161
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
2162
+ * stamp and signature is minted inside the body, and the invitation keys come from the
2163
+ * caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
2164
+ * since collides on `Revocation`'s primary key — a constraint failure, never retried, as
2165
+ * before). A lost update after a sibling tree of the transaction already synced arrives as a
2166
+ * partial commit and stays declined.
2167
+ *
1906
2168
  * The caller decides which invitations the machine could use, from its plan
1907
2169
  * (`CadreNode.planMachineRemoval`).
1908
2170
  */
@@ -1935,7 +2197,7 @@ export class ControlDatabase {
1935
2197
  removedDeviceToken: removed.has('DeviceToken'),
1936
2198
  withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
1937
2199
  };
1938
- });
2200
+ }, LOST_UPDATE_RETRY_POLICY);
1939
2201
  }
1940
2202
  /**
1941
2203
  * The guarded removals {@link removeMachine} runs, signed over each row's current stamp:
@@ -1986,6 +2248,12 @@ export class ControlDatabase {
1986
2248
  async insertStrand(strandId, type, ownerKey, signMessage, memberPrivateKey) {
1987
2249
  this.ensureInitialized();
1988
2250
  log('Inserting strand: %s (type: %s)', strandId, type);
2251
+ const insert = await this.signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey);
2252
+ await this.execWrite(insert.sql, insert.params, 'strand-insert');
2253
+ log('Strand inserted: %s', strandId);
2254
+ }
2255
+ /** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
2256
+ async signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey) {
1989
2257
  // Generate a unique stamp ID using the peer ID for distributed uniqueness
1990
2258
  const peerId = this.config.libp2pNode.peerId.toString();
1991
2259
  const stampId = generateStampId(peerId);
@@ -1997,12 +2265,53 @@ export class ControlDatabase {
1997
2265
  const signature = await signMessage(message);
1998
2266
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
1999
2267
  // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
2000
- await this.execWrite(`
2001
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2002
- with context OwnerKey = ?, Signature = ?
2003
- values (?, ?, ?, ?, ?, ?)
2004
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId], 'strand-insert');
2005
- log('Strand inserted: %s', strandId);
2268
+ return {
2269
+ sql: `
2270
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
2271
+ with context OwnerKey = ?, Signature = ?
2272
+ values (?, ?, ?, ?, ?, ?)
2273
+ `,
2274
+ params: [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId],
2275
+ };
2276
+ }
2277
+ /**
2278
+ * Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
2279
+ * row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
2280
+ * never a strand row without the key or invitation written beside it.
2281
+ *
2282
+ * Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
2283
+ * {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
2284
+ * invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
2285
+ * taken: signing may prompt the user, and the lock held across a prompt would stall every
2286
+ * other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
2287
+ *
2288
+ * No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
2289
+ * `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
2290
+ * `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
2291
+ *
2292
+ * A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
2293
+ * again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
2294
+ * stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
2295
+ */
2296
+ async insertFoundingRows(rows, ownerKey, signMessage) {
2297
+ this.ensureInitialized();
2298
+ const { strand, partyKey, formationInvite } = rows;
2299
+ log('Inserting founding rows: strand %s (type: %s, party key: %s, invitation: %s)', strand.id, strand.type, partyKey !== undefined, formationInvite !== undefined);
2300
+ const inserts = [await this.signStrandInsert(strand.id, strand.type, ownerKey, signMessage, strand.memberPrivateKey)];
2301
+ if (partyKey !== undefined) {
2302
+ inserts.push(await this.signStrandPartyKeyInsert(strand.id, partyKey, ownerKey, signMessage));
2303
+ }
2304
+ if (formationInvite) {
2305
+ const { token, sAppId, ...options } = formationInvite;
2306
+ inserts.push(await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, { ...options, strandId: strand.id }));
2307
+ }
2308
+ // Bare `exec`: the lock is held, and `execWrite` would take it again and self-deadlock.
2309
+ await this.lockedWithRetry(() => this.inTransaction('found strand', async () => {
2310
+ for (const insert of inserts) {
2311
+ await this.db.exec(insert.sql, insert.params);
2312
+ }
2313
+ }), {}, 'strand-found');
2314
+ log('Founding rows inserted: strand %s', strand.id);
2006
2315
  }
2007
2316
  /**
2008
2317
  * Delete a strand from the control database using an owner signature.
@@ -2091,18 +2400,25 @@ export class ControlDatabase {
2091
2400
  async insertStrandPartyKey(strandId, privateKey, ownerKey, signMessage) {
2092
2401
  this.ensureInitialized();
2093
2402
  log('Inserting strand party key for strand: %s', strandId);
2094
- const peerId = this.config.libp2pNode.peerId.toString();
2095
- const stampId = generateStampId(peerId);
2403
+ const insert = await this.signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage);
2404
+ await this.execWrite(insert.sql, insert.params, 'strand-party-key-insert');
2405
+ log('Strand party key inserted for strand: %s', strandId);
2406
+ }
2407
+ /** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
2408
+ async signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage) {
2409
+ const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
2096
2410
  // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
2097
2411
  // Id, PrivateKey, StampId.
2098
2412
  const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
2099
2413
  const signature = await signMessage(message);
2100
- await this.execWrite(`
2101
- insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
2102
- with context OwnerKey = ?, Signature = ?
2103
- values (?, ?, ?)
2104
- `, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
2105
- log('Strand party key inserted for strand: %s', strandId);
2414
+ return {
2415
+ sql: `
2416
+ insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
2417
+ with context OwnerKey = ?, Signature = ?
2418
+ values (?, ?, ?)
2419
+ `,
2420
+ params: [ownerKey, signature, strandId, privateKey, stampId],
2421
+ };
2106
2422
  }
2107
2423
  /**
2108
2424
  * Owner-signed removal of one `StrandPartyKey` row — the party's own membership
@@ -2386,32 +2702,44 @@ export class ControlDatabase {
2386
2702
  });
2387
2703
  }
2388
2704
  /**
2389
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
2390
- * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
2391
- * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
2392
- * the row is re-emitted as a fresh, broadcasting transaction.
2393
- *
2394
- * This is the write-while-alone re-replication primitive: a membership row that committed
2395
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
2396
- * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
2397
- * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
2398
- * and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
2399
- * owner branch requires them kept: they are the machine's own claim) — so it is safe
2400
- * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
2401
- * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
2402
- * `UpdatedAt` without re-signing would invalidate its self-signature).
2403
- *
2404
- * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
2405
- * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
2406
- * membership snapshot cannot have changed.
2407
- *
2408
- * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
2409
- * that removes the row in between makes the `update` match nothing, and this still returns
2410
- * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
2411
- * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
2412
- * re-add in the same window fails loudly instead, since the signature binds the retired
2413
- * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
2414
- * into the locked body.
2705
+ * Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
2706
+ * (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
2707
+ * `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
2708
+ * {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
2709
+ * transaction, or stands on a different owner's voucher.
2710
+ *
2711
+ * Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
2712
+ * committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
2713
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
2714
+ * the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
2715
+ * stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
2716
+ * (the owner branch requires them kept: they are the machine's own claim) — so it is safe
2717
+ * over a row whose peer has not self-published (`Sig` null), and this method itself skips a
2718
+ * row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
2719
+ * bumping `UpdatedAt` without re-signing would invalidate its self-signature).
2720
+ *
2721
+ * The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
2722
+ * bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
2723
+ * only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
2724
+ * writes whether or not the machine has self-published (the owner branch has no monotonic
2725
+ * rule, so the column simply keeps its value).
2726
+ *
2727
+ * The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
2728
+ * row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
2729
+ * what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
2730
+ * the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
2731
+ * at the moment that publish does. A re-run after losing to it reads the self-signed row and
2732
+ * reports `'self-published'` instead of writing the owner branch over it — which would rewrite
2733
+ * `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
2734
+ * resolvers until the machine's next heartbeat. The decision has to be the body's for that;
2735
+ * a caller's pre-check outside the lock is only a fast path (see below).
2736
+ *
2737
+ * An absent row — none, or its stamp retired, or a stampless row no writer produces — is
2738
+ * `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
2739
+ * writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
2740
+ * ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
2741
+ * in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
2742
+ * which only re-reads the member set.
2415
2743
  *
2416
2744
  * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
2417
2745
  * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
@@ -2434,29 +2762,46 @@ export class ControlDatabase {
2434
2762
  * VouchOwner/VouchSig) rather than inherit this rebinding.
2435
2763
  *
2436
2764
  * @param peerId - the membership row to re-touch.
2437
- * @param updatedAt - the strictly-increasing freshness stamp to write.
2765
+ * @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
2438
2766
  * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
2439
- * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
2440
- * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
2767
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
2768
+ * called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
2769
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}).
2441
2770
  */
2442
- async reauthorizeCadrePeer(peerId, updatedAt, ownerKey, signMessage) {
2771
+ async reauthorizeCadrePeer(peerId, mode, ownerKey, signMessage) {
2443
2772
  this.ensureInitialized();
2444
- const stampId = await this.queryStampId('CadrePeer', peerId);
2445
- if (stampId === null) {
2446
- log('reauthorizeCadrePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
2447
- return false;
2773
+ const outside = retouchTarget(await this.findCadrePeerRow(peerId, true), mode);
2774
+ if (typeof outside === 'string') {
2775
+ log('reauthorizeCadrePeer(%s): %s for %s (nothing to write)', mode, outside, peerId);
2776
+ return outside;
2448
2777
  }
2449
- const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, stampId]));
2450
- await this.mutateCadrePeer('peer-reauthorize', async () => {
2778
+ return await this.mutateCadrePeer('peer-reauthorize', async () => {
2779
+ // retry: false — inside the locked write body (see queryStampId's NOTE).
2780
+ const target = retouchTarget(await this.findCadrePeerRow(peerId, false), mode);
2781
+ if (typeof target === 'string') {
2782
+ log('reauthorizeCadrePeer(%s): %s for %s since the pre-check (nothing to write)', mode, target, peerId);
2783
+ return target;
2784
+ }
2785
+ const signature = await signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, target.stampId]));
2451
2786
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
2452
- await this.db.exec(`
2453
- update CadreControl.CadrePeer
2454
- with context OwnerKey = ?, Signature = ?
2455
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2456
- where PeerId = ?
2457
- `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
2458
- });
2459
- return true;
2787
+ if (mode === 'rebroadcast') {
2788
+ await this.db.exec(`
2789
+ update CadreControl.CadrePeer
2790
+ with context OwnerKey = ?, Signature = ?
2791
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
2792
+ where PeerId = ?
2793
+ `, [ownerKey, signature, Math.max(Date.now(), target.updatedAt + 1), ownerKey, signature, peerId]);
2794
+ }
2795
+ else {
2796
+ await this.db.exec(`
2797
+ update CadreControl.CadrePeer
2798
+ with context OwnerKey = ?, Signature = ?
2799
+ set VouchOwner = ?, VouchSig = ?, VouchUsage = null
2800
+ where PeerId = ?
2801
+ `, [ownerKey, signature, ownerKey, signature, peerId]);
2802
+ }
2803
+ return 'retouched';
2804
+ }, LOST_UPDATE_RETRY_POLICY);
2460
2805
  }
2461
2806
  /**
2462
2807
  * Owner-signed removal of one `CadrePeer` membership row, notifying the membership
@@ -2477,9 +2822,15 @@ export class ControlDatabase {
2477
2822
  *
2478
2823
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
2479
2824
  * when a row was actually removed.
2825
+ *
2826
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
2827
+ * at the moment of the removal (its first publish lands about a second after it starts), and
2828
+ * the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
2829
+ * and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
2830
+ * binds, so the re-run reads the published row and removes it.
2480
2831
  */
2481
2832
  deleteCadrePeer(peerId, ownerKey, signMessage) {
2482
- return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
2833
+ return this.mutateCadrePeer('peer-remove', () => this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage), LOST_UPDATE_RETRY_POLICY);
2483
2834
  }
2484
2835
  /**
2485
2836
  * Owner-signed removal of one peer's `DeviceToken` row (logout / token invalidation).
@@ -2566,9 +2917,8 @@ export class ControlDatabase {
2566
2917
  /**
2567
2918
  * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
2568
2919
  * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
2569
- * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
2570
- * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
2571
- * lock and, where one is needed, the transaction.
2920
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
2921
+ * supplies the lock and, where one is needed, the transaction.
2572
2922
  *
2573
2923
  * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
2574
2924
  * than leaning on the column default — the seat-at-zero rule is load-bearing for
@@ -2622,9 +2972,10 @@ export class ControlDatabase {
2622
2972
  * statement, and without the clause the reap would delete the owner's brand-new row.
2623
2973
  * With it the statement matches nothing and the reap is a silent no-op (the schema
2624
2974
  * branch would also refuse that delete, but as a constraint error thrown into a
2625
- * background sweep — matched-nothing is the better failure mode). Like
2626
- * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
2627
- * harmless — nothing was deleted, and the next sweep re-reads.
2975
+ * background sweep — matched-nothing is the better failure mode). A race lost AFTER the
2976
+ * guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
2977
+ * its lock and reports what it found); harmless — nothing was deleted, and the next sweep
2978
+ * re-reads.
2628
2979
  *
2629
2980
  * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
2630
2981
  * committed tombstone that authorizes the reap, so a reap files no second tombstone.
@@ -2651,7 +3002,7 @@ export class ControlDatabase {
2651
3002
  * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
2652
3003
  *
2653
3004
  * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
2654
- * reconcile pass runs while the node has at least one control connection.
3005
+ * reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
2655
3006
  */
2656
3007
  async reapRevokedRow(table, rowKey, stampId) {
2657
3008
  this.ensureInitialized();
@@ -2709,8 +3060,8 @@ export class ControlDatabase {
2709
3060
  * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
2710
3061
  * that converged on a removal while holding the removed row keeps that row as inert
2711
3062
  * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
2712
- * control-cohort reconcile pass is that something, gated on the node having at least one
2713
- * control connection (a reap is a write, and a write committed alone is local-only).
3063
+ * control-cohort reconcile pass is that something, gated on a sibling being reachable
3064
+ * (a reap is a write, and a write committed alone is local-only).
2714
3065
  *
2715
3066
  * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
2716
3067
  * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
@@ -2743,12 +3094,11 @@ export class ControlDatabase {
2743
3094
  *
2744
3095
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
2745
3096
  * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
2746
- * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
2747
- * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
2748
- * only while `Revocation` has never been written; the ledger marker
2749
- * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
3097
+ * the common case (a party that has never revoked anyone) a single scan per pass, served
3098
+ * from the `Revocation` membership view
3099
+ * ({@link readMembershipRows}) unless the table changed since the last read.
2750
3100
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
2751
- * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
3101
+ * lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
2752
3102
  * party; if the table ever grows, persist a node-local high-water mark of what has
2753
3103
  * already been reaped instead of re-scanning everything (the same bound the sweep in
2754
3104
  * `CadreNode.drainPendingRevocations` wants).
@@ -2856,69 +3206,6 @@ export class ControlDatabase {
2856
3206
  return executed;
2857
3207
  }, {}, 'revocation-reissue');
2858
3208
  }
2859
- /**
2860
- * Owner-signed filing of the singleton `Revocation` ledger marker
2861
- * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
2862
- *
2863
- * Why it exists: while `Revocation` has never been written, this node does not hold its
2864
- * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
2865
- * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
2866
- * reads this table. Once any row exists the block is held and re-checked at most once per
2867
- * read-repair window, like every other populated table. The schema's table comment says
2868
- * why the row can never read as a retirement.
2869
- *
2870
- * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
2871
- * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
2872
- * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
2873
- * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
2874
- * full composite primary key, which is served as a point lookup that can miss an existing
2875
- * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
2876
- *
2877
- * Two owners filing at once: the loser either sees the marker in its guard or is refused
2878
- * on the primary key, and both answer `'already-open'`.
2879
- *
2880
- * The caller decides WHEN. A marker committed while the node is alone is local-only and
2881
- * can fork the collection, so {@link CadreNode} files it only while connected; this
2882
- * method does not look at connectivity.
2883
- *
2884
- * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
2885
- * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
2886
- * private key, returning a base64url signature.
2887
- * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
2888
- * already there.
2889
- */
2890
- async openRevocationLedger(ownerKey, signMessage) {
2891
- this.ensureInitialized();
2892
- const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
2893
- try {
2894
- return await this.lockedWithRetry(async () => {
2895
- if (await this.revocationLedgerFiled()) {
2896
- return 'already-open';
2897
- }
2898
- // execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
2899
- await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
2900
- log('Revocation ledger marker filed');
2901
- return 'opened';
2902
- }, {}, 'revocation-ledger-open');
2903
- }
2904
- catch (error) {
2905
- if (!isRevocationLedgerConflict(error)) {
2906
- throw error;
2907
- }
2908
- log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
2909
- return 'already-open';
2910
- }
2911
- }
2912
- /**
2913
- * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
2914
- * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
2915
- * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
2916
- */
2917
- async revocationLedgerFiled() {
2918
- const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
2919
- const rows = await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revocation-ledger', false);
2920
- return rows.some(row => row.StampId === stampId);
2921
- }
2922
3209
  /**
2923
3210
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
2924
3211
  *
@@ -2966,10 +3253,26 @@ export class ControlDatabase {
2966
3253
  * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
2967
3254
  * second call replaces the first rather than fanning out, so a stale node can
2968
3255
  * never keep receiving notifications for a database it no longer owns.
3256
+ *
3257
+ * Only this database's own writes notify here, awaited inside the write so the gate is
3258
+ * current before the write returns. A membership change replicated in from another
3259
+ * machine reaches the gate through the subscription `CadreNode.start` takes on the
3260
+ * membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
3261
+ * lands in storage; one storage never hears of waits for the reconcile pass's backstop.
2969
3262
  */
2970
3263
  setMembershipChangeListener(listener) {
2971
3264
  this.membershipListener = listener;
2972
3265
  }
3266
+ /**
3267
+ * Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
3268
+ * made here and changes replicated into this node's storage alike, on the single-flight
3269
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
3270
+ * still live before it drains the write queue.
3271
+ */
3272
+ watchTables(tables, onChange, options) {
3273
+ this.ensureInitialized();
3274
+ return this.tableWatches.watch(this.db, tables, onChange, options);
3275
+ }
2973
3276
  /**
2974
3277
  * Wire (or clear, with null) the single listener notified after every committed
2975
3278
  * guarded-table delete. Same ownership contract as
@@ -3028,8 +3331,11 @@ export class ControlDatabase {
3028
3331
  * exhaustion.
3029
3332
  *
3030
3333
  * @param reason - Label for the log line only (e.g. `'peer-insert'`).
3334
+ * @param policy - The retry policy, default unless the body has earned a wider one: the three
3335
+ * `CadrePeer` writers whose bodies re-read the row and decide inside pass
3336
+ * `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
3031
3337
  */
3032
- async mutateCadrePeer(reason, body) {
3338
+ async mutateCadrePeer(reason, body, policy = {}) {
3033
3339
  this.ensureInitialized();
3034
3340
  return this.lockedWithRetry(async () => {
3035
3341
  this.assertCommitBoundary(reason, 'on entry to');
@@ -3037,7 +3343,7 @@ export class ControlDatabase {
3037
3343
  this.assertCommitBoundary(reason, 'on return from');
3038
3344
  await this.notifyMembershipChanged(reason);
3039
3345
  return result;
3040
- }, {}, reason);
3346
+ }, policy, reason);
3041
3347
  }
3042
3348
  /**
3043
3349
  * Run one local write under the database-wide write lock, serializing it against
@@ -3051,8 +3357,8 @@ export class ControlDatabase {
3051
3357
  * class: every public write method of this class runs its statement(s) under it, and
3052
3358
  * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
3053
3359
  * `Database` directly, so it is covered too. The race is
3054
- * real, not theoretical: the background self-record publish on a control connection
3055
- * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
3360
+ * real, not theoretical: the background self-record publish when a sibling becomes
3361
+ * reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
3056
3362
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
3057
3363
  * assert.
3058
3364
  *
@@ -3076,6 +3382,11 @@ export class ControlDatabase {
3076
3382
  * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
3077
3383
  * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
3078
3384
  * attempt: a retry re-presents the exact signed message the first attempt presented.
3385
+ * A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
3386
+ * its re-run is a re-decision over a row another writer changed, not a re-presentation of
3387
+ * the same write, so its read of the row, its decision and its signature all run in the
3388
+ * body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
3389
+ * under {@link deleteCadrePeer}, {@link removeMachine}).
3079
3390
  */
3080
3391
  async withWriteLock(fn) {
3081
3392
  // Chain behind the current tail regardless of how it settled, then park a
@@ -3116,9 +3427,14 @@ export class ControlDatabase {
3116
3427
  * `initialized` is set — so it reaches this method directly rather than through
3117
3428
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
3118
3429
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
3119
- * rather than carving out a lockless path. It is also the ONLY caller that passes a
3120
- * `policy` — everything else takes the default, whose classifier and attempt count are
3121
- * unchanged by that call site existing.
3430
+ * rather than carving out a lockless path.
3431
+ *
3432
+ * Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
3433
+ * call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
3434
+ * row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
3435
+ * {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
3436
+ * policy parameter). Everything else takes the default, whose classifier and attempt count
3437
+ * are unchanged by those call sites existing.
3122
3438
  *
3123
3439
  * `label` names the operation in this loop's debug lines and nothing else
3124
3440
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
@@ -3249,6 +3565,15 @@ export class ControlDatabase {
3249
3565
  async insertFormationInvite(token, sAppId, ownerKey, signMessage, options = {}) {
3250
3566
  this.ensureInitialized();
3251
3567
  log('Inserting formation invite: %s', token);
3568
+ const insert = await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options);
3569
+ await this.execWrite(insert.sql, insert.params, 'formation-invite-insert');
3570
+ log('Formation invite inserted: %s', token);
3571
+ }
3572
+ /**
3573
+ * The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
3574
+ * {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
3575
+ */
3576
+ async signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options) {
3252
3577
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
3253
3578
  // Build each bound field exactly as the deferred CHECK sees it post-coercion:
3254
3579
  // - ExpiresAt: engine-canonical datetime string (or '' when absent), sourced from
@@ -3272,20 +3597,22 @@ export class ControlDatabase {
3272
3597
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
3273
3598
  // signed source-of-truth and the stored value are produced once. StampId is a real,
3274
3599
  // unique column (single-use anti-replay), no longer a context value.
3275
- await this.execWrite(`
3276
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3277
- with context OwnerKey = ?, Signature = ?
3278
- values (?, ?, ?, ?, ?, ?, ?)
3279
- `, [
3280
- ownerKey, signature,
3281
- token, sAppId,
3282
- expiresAtCanonical,
3283
- options.totalUses ?? null,
3284
- options.validationUrl ?? null,
3285
- options.strandId ?? null,
3286
- stampId,
3287
- ], 'formation-invite-insert');
3288
- log('Formation invite inserted: %s', token);
3600
+ return {
3601
+ sql: `
3602
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
3603
+ with context OwnerKey = ?, Signature = ?
3604
+ values (?, ?, ?, ?, ?, ?, ?)
3605
+ `,
3606
+ params: [
3607
+ ownerKey, signature,
3608
+ token, sAppId,
3609
+ expiresAtCanonical,
3610
+ options.totalUses ?? null,
3611
+ options.validationUrl ?? null,
3612
+ options.strandId ?? null,
3613
+ stampId,
3614
+ ],
3615
+ };
3289
3616
  }
3290
3617
  /**
3291
3618
  * Redeem a `FormationInvite` by inserting the `Strand` row and a matching
@@ -3372,14 +3699,13 @@ export class ControlDatabase {
3372
3699
  * Use {@link redeemInvitation} instead when the strand must be created by
3373
3700
  * consent atomically with the usage.
3374
3701
  *
3375
- * The strand's live `StampId` is read first and written onto the usage row:
3702
+ * The strand's live `StampId` is written onto the usage row:
3376
3703
  * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
3377
3704
  * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
3378
- * record authorizes exactly the strand ROW it was recorded against. A missing strand
3379
- * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
3380
- * ordinary "host strand has not converged yet" case is already reported as `missing`
3381
- * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
3382
- * point is a genuine race and deserves a named error, not a silent rollback.
3705
+ * record authorizes exactly the strand ROW it was recorded against. A caller that already
3706
+ * read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
3707
+ * missing strand THROWS {@link MissingHostStrandError} rather than being left to the
3708
+ * deferred `StrandExists` CHECK.
3383
3709
  *
3384
3710
  * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
3385
3711
  * path — the one production actually races on (a bound invite published by `cadre-web` /
@@ -3393,7 +3719,7 @@ export class ControlDatabase {
3393
3719
  const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
3394
3720
  // The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
3395
3721
  // consent record authorizes.
3396
- const strandStampId = await this.queryStrandStampId(strandId);
3722
+ const strandStampId = params.strandStampId ?? await this.queryStrandStampId(strandId);
3397
3723
  if (strandStampId === null) {
3398
3724
  throw new MissingHostStrandError(strandId, token);
3399
3725
  }
@@ -3514,7 +3840,7 @@ export class ControlDatabase {
3514
3840
  * permissive PRE-check that runs ahead of it, and a transiently short read costs each of
3515
3841
  * them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
3516
3842
  * {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
3517
- * generic refusal; `ControlFormationUsageRecorder.isTokenUsed` reports not-used and lets
3843
+ * generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
3518
3844
  * the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
3519
3845
  * holds the stranger-admission door open slightly longer.
3520
3846
  *
@@ -3547,12 +3873,12 @@ export class ControlDatabase {
3547
3873
  /**
3548
3874
  * Is any `FormationInvite` row still redeemable — unexpired AND with usage
3549
3875
  * below its `TotalUses`? A null `ExpiresAt` never expires and a null
3550
- * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
3551
- * per-token semantics (`isTokenValid` / `isTokenUsed`).
3876
+ * `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
3877
+ * (`formation-token.ts`).
3552
3878
  *
3553
3879
  * Answers the control-network connection gate's coarse "does this node expect
3554
3880
  * a stranger?" question, which has no token to ask about. The expiry
3555
- * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
3881
+ * comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
3556
3882
  * invite the formation handler would reject can never hold the gate open.
3557
3883
  *
3558
3884
  * The scan is deliberately not pushed into SQL: nothing else here compares a
@@ -3734,8 +4060,10 @@ export class ControlDatabase {
3734
4060
  * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
3735
4061
  * question, and `CadreInviteUsage.Authorized`'s at redemption.
3736
4062
  *
3737
- * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
3738
- * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
4063
+ * A redemption reads the row once, before its write, and carries it into
4064
+ * {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
4065
+ * {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
4066
+ * the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
3739
4067
  */
3740
4068
  async queryCadreInvite(key, retry = true) {
3741
4069
  this.ensureInitialized();
@@ -3752,19 +4080,15 @@ export class ControlDatabase {
3752
4080
  * {@link queryCadrePeers}' reason.
3753
4081
  */
3754
4082
  async queryCadreInvites(retry = true) {
3755
- this.ensureInitialized();
3756
- const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
3757
- return rows.map(cadreInviteRowOf);
4083
+ return (await this.readMembershipRows(this.cadreInviteView, retry)).map(cadreInviteRowOf);
3758
4084
  }
3759
4085
  /**
3760
4086
  * Every `CadreInviteUsage` row this node holds — the redemption records the membership
3761
4087
  * predicate verifies invitation-admitted rows against. Same `retry` contract as
3762
- * {@link queryCadreInvites}.
4088
+ * {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
3763
4089
  */
3764
4090
  async queryCadreInviteUsages(retry = true) {
3765
- this.ensureInitialized();
3766
- const rows = await this.readRows('select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage', undefined, 'cadre-invite-usages', retry);
3767
- return rows.map(cadreInviteUsageRowOf);
4091
+ return (await this.readMembershipRows(this.cadreInviteUsageView, retry)).map(cadreInviteUsageRowOf);
3768
4092
  }
3769
4093
  /**
3770
4094
  * How many times a cadre invitation has been redeemed (its `CadreInviteUsage` rows), served
@@ -3807,6 +4131,9 @@ export class ControlDatabase {
3807
4131
  // counts make that free today; if a long-lived cadre accumulates thousands and inbound
3808
4132
  // upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
3809
4133
  // first needs every device they admitted re-vouched, so the chain no longer needs them).
4134
+ // NOTE: this scan reaches storage while queryCadreInvites serves the same rows from the
4135
+ // membership view; if the stranger path's reads ever near the gate's 2 s deadline, walk
4136
+ // queryCadreInvites() here instead.
3810
4137
  const metered = [];
3811
4138
  let unlimitedLive = false;
3812
4139
  for (const row of await this.readRows('select Key, ExpiresAt, TotalUses, IssuerKey, StampId from CadreControl.CadreInvite', undefined, 'live-cadre-invites')) {
@@ -3837,16 +4164,21 @@ export class ControlDatabase {
3837
4164
  }
3838
4165
  /**
3839
4166
  * Is this one cadre invitation still redeemable here — the conditions
3840
- * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
3841
- * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
3842
- * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
3843
- * judged by whichever owner it names as issuer. A permissive pre-check like
3844
- * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
3845
- * what enforce every condition.
4167
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
4168
+ * node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
4169
+ * none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
4170
+ * verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
4171
+ * issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
4172
+ * and the redemption's own write are what enforce every condition.
4173
+ *
4174
+ * The use count read here is read again inside the redemption's write lock
4175
+ * ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
4176
+ * pushed the control store before the write, so the holder of a spent invitation is refused
4177
+ * without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
3846
4178
  */
3847
- async isCadreInviteLive(invite, nowMs = Date.now()) {
4179
+ async isCadreInviteLive(invite, held, nowMs = Date.now()) {
3848
4180
  this.ensureInitialized();
3849
- const row = await this.queryCadreInvite(invite.key) ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
4181
+ const row = held ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
3850
4182
  return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
3851
4183
  }
3852
4184
  /**
@@ -3994,12 +4326,12 @@ export class ControlDatabase {
3994
4326
  */
3995
4327
  async redeemCadreInvite(params) {
3996
4328
  this.ensureInitialized();
3997
- const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
4329
+ const { inviteKey, peerId, peerKey, record, usageStampId, inviteSig, peerSig } = params;
3998
4330
  const nowMs = params.nowMs ?? Date.now();
3999
4331
  const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
4000
4332
  log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
4001
4333
  return this.mutateCadrePeer('cadre-invite-redeem', async () => {
4002
- const invite = await this.queryCadreInvite(inviteKey, false);
4334
+ const invite = params.held ?? await this.queryCadreInvite(inviteKey, false);
4003
4335
  if (invite === null) {
4004
4336
  throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
4005
4337
  }
@@ -4018,10 +4350,11 @@ export class ControlDatabase {
4018
4350
  const nowCanonical = await canonicalDatetime(this.db, nowMs);
4019
4351
  await this.inTransaction('cadre-invite-redeem', async () => {
4020
4352
  await this.db.exec(`
4021
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
4353
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig, VouchUsage)
4022
4354
  with context OwnerKey = null, Signature = null
4023
- values (?, ?, ?, ?, null, ?, ?, null, ?)
4024
- `, [peerId, peerKey, multiaddr, nowMs, peerStampId, invite.issuerKey, usageStampId]);
4355
+ values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)
4356
+ `, [peerId, peerKey, multiaddr, record?.updatedAt ?? nowMs, record?.sig ?? null, record?.profile ?? null,
4357
+ peerStampId, invite.issuerKey, usageStampId]);
4025
4358
  await this.db.exec(`
4026
4359
  insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
4027
4360
  with context Now = ?
@@ -4119,7 +4452,9 @@ export class ControlDatabase {
4119
4452
  /**
4120
4453
  * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
4121
4454
  * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
4122
- * away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
4455
+ * away. Read once per redemption and carried into {@link isOwnerGrantLive} and
4456
+ * {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
4457
+ * handed none and reads inside its locked body.
4123
4458
  */
4124
4459
  async queryOwnerGrant(key, retry = true) {
4125
4460
  this.ensureInitialized();
@@ -4134,15 +4469,11 @@ export class ControlDatabase {
4134
4469
  * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
4135
4470
  */
4136
4471
  async queryOwnerGrants(retry = true) {
4137
- this.ensureInitialized();
4138
- const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
4139
- return rows.map(ownerGrantRowOf);
4472
+ return (await this.readMembershipRows(this.ownerGrantView, retry)).map(ownerGrantRowOf);
4140
4473
  }
4141
- /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
4474
+ /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}; both come from their membership views ({@link readMembershipRows}). */
4142
4475
  async queryOwnerGrantUsages(retry = true) {
4143
- this.ensureInitialized();
4144
- const rows = await this.readRows('select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage', undefined, 'owner-grant-usages', retry);
4145
- return rows.map(ownerGrantUsageRowOf);
4476
+ return (await this.readMembershipRows(this.ownerGrantUsageView, retry)).map(ownerGrantUsageRowOf);
4146
4477
  }
4147
4478
  /**
4148
4479
  * How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
@@ -4154,14 +4485,16 @@ export class ControlDatabase {
4154
4485
  }
4155
4486
  /**
4156
4487
  * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
4157
- * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
4158
- * node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
4159
- * when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
4160
- * redemption's own write enforce every condition.
4488
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
4489
+ * this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
4490
+ * `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
4491
+ * permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
4492
+ * condition. Its use count is read again inside the write lock, for
4493
+ * {@link isCadreInviteLive}'s reason.
4161
4494
  */
4162
- async isOwnerGrantLive(grant, nowMs = Date.now()) {
4495
+ async isOwnerGrantLive(grant, held, nowMs = Date.now()) {
4163
4496
  this.ensureInitialized();
4164
- const row = await this.queryOwnerGrant(grant.key) ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
4497
+ const row = held ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
4165
4498
  return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
4166
4499
  }
4167
4500
  /**
@@ -4224,7 +4557,7 @@ export class ControlDatabase {
4224
4557
  const nowMs = params.nowMs ?? Date.now();
4225
4558
  log('Redeeming owner grant %s for %s', grantKey, seatedKey);
4226
4559
  return this.mutateCadrePeer('owner-grant-redeem', async () => {
4227
- const grant = await this.queryOwnerGrant(grantKey, false);
4560
+ const grant = params.held ?? await this.queryOwnerGrant(grantKey, false);
4228
4561
  if (grant === null) {
4229
4562
  throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
4230
4563
  }
@@ -4261,7 +4594,10 @@ export class ControlDatabase {
4261
4594
  /**
4262
4595
  * Close the database and cleanup resources.
4263
4596
  *
4264
- * Drains the local-write chain first: {@link withWriteLock} can park a write behind
4597
+ * Disposes every {@link watchTables} subscription before anything else, so a commit that
4598
+ * lands while the write chain drains schedules no run, and again before dropping the handle.
4599
+ *
4600
+ * Then drains the local-write chain: {@link withWriteLock} can park a write behind
4265
4601
  * others across an await, and a queued closure evaluates `this.db!` only when it
4266
4602
  * finally runs — so nulling the handle out from under it would throw a TypeError on a
4267
4603
  * null handle instead of committing. The tail never rejects (it swallows both
@@ -4272,11 +4608,17 @@ export class ControlDatabase {
4272
4608
  * deadline) if a write can ever hang.
4273
4609
  */
4274
4610
  async close() {
4611
+ this.tableWatches.disposeAll();
4275
4612
  await this.writeQueue;
4276
4613
  if (this.collectionFactory) {
4277
4614
  await this.collectionFactory.shutdown();
4278
4615
  this.collectionFactory = null;
4279
4616
  }
4617
+ this.txnBridge = null;
4618
+ // Again: a subscription taken during the awaits above would keep its timers past close.
4619
+ this.tableWatches.disposeAll();
4620
+ // Here, not first: a read started during the awaits above must not install either.
4621
+ this.invalidateMembershipViews();
4280
4622
  if (this.db) {
4281
4623
  void this.db.close();
4282
4624
  this.db = null;