@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
@@ -2,9 +2,12 @@ import { Database } from '@quereus/quereus';
2
2
  import type { SqlParameters } from '@quereus/quereus';
3
3
  import type { Libp2p } from '@libp2p/interface';
4
4
  import type { IRepo } from '@optimystic/db-core';
5
- import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
5
+ import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
6
6
  import type { ControlTable, RevocableTable, BoundControlDomain, ControlAction, CadreInviteSignedFields, OwnerGrantSignedFields } from './control-authorization.js';
7
+ import type { ControlWriteRetryOptions } from './control-write-retry.js';
7
8
  import type { ControlRetryAbandonment } from './control-retry.js';
9
+ import type { TableChangeWatch, TableChangeWatchOptions } from './table-change-watch.js';
10
+ import type { FormationInviteFacts } from './formation-token.js';
8
11
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
9
12
  /**
10
13
  * Generate a unique stamp ID for transaction authorization.
@@ -18,12 +21,11 @@ export declare function generateStampId(peerId: string): string;
18
21
  * present locally.
19
22
  *
20
23
  * Consent is bound to the strand's one-off `StampId` (see the consent branch of
21
- * `Strand.AuthorizedInsert`), so the writer must read the live row before inserting.
22
- * The ordinary "host strand has not converged on this responder yet" case is caught
23
- * earlier and reported as `missing` by `ControlFormationUsageRecorder.resolveStrand`;
24
- * reaching here means the row vanished between that check and the write, which is a
25
- * genuine race and is surfaced rather than left to fail the deferred `StrandExists`
26
- * CHECK at commit (which would drop the write with a far less legible error).
24
+ * `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
25
+ * inserting, and throws this when the row is absent rather than leaving it to fail the
26
+ * deferred `StrandExists` CHECK at commit (a far less legible error). The formation
27
+ * responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
28
+ * reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
27
29
  */
28
30
  export declare class MissingHostStrandError extends Error {
29
31
  readonly strandId: string;
@@ -59,16 +61,16 @@ export declare class FormationAbortedError extends Error {
59
61
  * the write, inside {@link ControlDatabase.redeemInvitation} /
60
62
  * {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
61
63
  * ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
62
- * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked pre-check
63
- * `ControlFormationUsageRecorder.authorizeUsage` runs before the manager issues a membership
64
- * pass — and off nothing else. It exists because
64
+ * ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
65
+ * `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
66
+ * manager issues a membership pass — and off nothing else. It exists because
65
67
  * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
66
68
  * schema's own count-based cap clause, which the manager reports as a retryable
67
69
  * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
68
70
  * Two redemptions of one token racing on the SAME node serialize behind the local write
69
- * queue, so the loser reads the winner's committed row and is refused here by name —
70
- * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
71
- * ran before either redemption wrote anything.
71
+ * queue, so the loser reads the winner's committed row and is refused here by name — the
72
+ * use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
73
+ * read before either redemption wrote anything.
72
74
  */
73
75
  export declare class InvitationExhaustedError extends Error {
74
76
  readonly token: string;
@@ -324,6 +326,56 @@ export declare function ownerKeyLabel(label: string | null | undefined): string
324
326
  * other control writes, sign outside the lock and re-check the stamp inside it.
325
327
  */
326
328
  export type ControlSign = (message: Uint8Array) => string | Promise<string>;
329
+ /**
330
+ * Signs this node's own address record over its `CadrePeer` row as it stands —
331
+ * {@link ControlDatabase.updateSelfPeerRecord}'s callback, run inside the locked write body on
332
+ * every attempt, so the record written is always stamped past the row that was actually read.
333
+ */
334
+ export type SelfRecordSigner = (current: PeerAddressRecord) => PeerAddressRecord;
335
+ /**
336
+ * Which owner re-touch {@link ControlDatabase.reauthorizeCadrePeer} makes over a `CadrePeer` row:
337
+ *
338
+ * - `'rebroadcast'` — the write-while-alone drain re-emitting a row that committed local-only. It
339
+ * bumps `UpdatedAt` so the row goes out as a fresh transaction, and writes only while the row's
340
+ * `Sig` is null: once the machine has self-published, the row is its to refresh, and a bump
341
+ * without a re-sign would leave its signature unverifiable.
342
+ * - `'revouch'` — an owner rebinding the row's voucher to its own key before the vouching key is
343
+ * removed (`CadreNode.removeOwner`). Written whether or not the machine has self-published, and
344
+ * leaves `UpdatedAt` untouched, so a self-signed record stays verifiable.
345
+ */
346
+ export type CadrePeerRetouchMode = 'rebroadcast' | 'revouch';
347
+ /**
348
+ * What {@link ControlDatabase.reauthorizeCadrePeer} found and did: the row was re-touched; no
349
+ * readable row exists (absent, or its stamp retired); or — `'rebroadcast'` only — the machine has
350
+ * self-published since, so the row is no longer the owner's to re-emit. The two no-write outcomes
351
+ * are decided over the row as read inside the locked body, so a caller can act on them.
352
+ */
353
+ export type CadrePeerRetouchOutcome = 'retouched' | 'absent' | 'self-published';
354
+ /** A formation invitation written with a founding ({@link FoundingRows}), bound to the strand founded. */
355
+ export interface FoundingInvite {
356
+ /** The invitation token, the `FormationInvite` primary key. */
357
+ token: string;
358
+ /** The sApp a redeemed strand will use. */
359
+ sAppId: string;
360
+ /** Epoch ms after which it cannot be redeemed; omit for never. */
361
+ expiresAtMs?: number;
362
+ /** How many redemptions it allows; omit for unlimited. */
363
+ totalUses?: number;
364
+ /** Web hook sent the disclosure; a `ValidationKey` then signs off each redemption. */
365
+ validationUrl?: string;
366
+ }
367
+ /** The control rows one founding writes together; see {@link ControlDatabase.insertFoundingRows}. */
368
+ export interface FoundingRows {
369
+ strand: {
370
+ id: string;
371
+ type: 'o' | 'c';
372
+ memberPrivateKey?: string;
373
+ };
374
+ /** This party's membership identity key for a closed strand; absent for an open strand, or when a stored key is adopted. */
375
+ partyKey?: string;
376
+ /** A formation invitation bound to this strand (its `StrandId` is `strand.id`). */
377
+ formationInvite?: FoundingInvite;
378
+ }
327
379
  /** What an owner asks {@link ControlDatabase.insertCadreInvite} (or {@link ControlDatabase.signCadreInvite}) to sign. */
328
380
  export interface CadreInviteFields {
329
381
  /** The invitation public key (base64url ed25519), the row's primary key. */
@@ -336,14 +388,15 @@ export interface CadreInviteFields {
336
388
  totalUses?: number | null;
337
389
  }
338
390
  /** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
339
- type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
391
+ type StrandIdTable = Extract<RevocableTable, 'Strand' | 'StrandPartyKey' | 'JoinedStrand'>;
340
392
  /**
341
393
  * Did this write to `table` fail because the strand id is already seated there?
342
394
  *
343
395
  * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
344
396
  * landed" (re-read, and no-op when the row matches) apart from every other rejection —
345
397
  * an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
346
- * the same test tells "another machine of this party already published this join" apart.
398
+ * the same test tells "another machine of this party already published this join" apart, and
399
+ * for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
347
400
  *
348
401
  * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
349
402
  * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
@@ -390,17 +443,6 @@ export declare class PendingJoinChangedError extends Error {
390
443
  readonly live: PendingJoin | null;
391
444
  constructor(id: string, live: PendingJoin | null);
392
445
  }
393
- /**
394
- * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
395
- * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
396
- * block. The schema's table comment says why that matters and why the row can never read
397
- * as a retirement; `RowIsGone` pins this exact triple.
398
- */
399
- export declare const REVOCATION_LEDGER_MARKER: {
400
- readonly tableName: "Revocation";
401
- readonly rowKey: "ledger";
402
- readonly stampId: "opened";
403
- };
404
446
  /**
405
447
  * Project `CadrePeer` rows into the {@link SeedPeer} list a seed, or a cadre invitation
406
448
  * redemption reply, carries. `minterPeerId` is the machine minting the seed or answering the
@@ -431,16 +473,8 @@ export declare function projectSeedPeers(rows: readonly CadrePeerRow[], minterPe
431
473
  export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey", "JoinedStrand", "JoinRequest"];
432
474
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
433
475
  /** A `FormationInvite` row as {@link ControlDatabase.queryFormationInvite} reads it. */
434
- export interface FormationInviteRow {
476
+ export interface FormationInviteRow extends FormationInviteFacts {
435
477
  token: string;
436
- sAppId: string;
437
- /** Parsed epoch ms of the stored `datetime`; null when the invitation never expires. */
438
- expiresAtMs: number | null;
439
- /** Null is unlimited. */
440
- totalUses: number | null;
441
- validationUrl: string | null;
442
- /** A bound invitation's host strand; null on the responder-provisions path. */
443
- strandId: string | null;
444
478
  }
445
479
  /**
446
480
  * Notified after a membership write (a `CadreControl.CadrePeer` or `OwnerKey` row, see
@@ -513,6 +547,7 @@ export interface ControlDatabaseConfig {
513
547
  export declare class ControlDatabase {
514
548
  private db;
515
549
  private collectionFactory;
550
+ private txnBridge;
516
551
  private readonly config;
517
552
  private initialized;
518
553
  private membershipListener;
@@ -520,6 +555,18 @@ export declare class ControlDatabase {
520
555
  private controlWriteAbandonedListener;
521
556
  /** Tail of the local-write chain — see {@link withWriteLock}. */
522
557
  private writeQueue;
558
+ /** Subscriptions handed out by {@link watchTables}. */
559
+ private readonly tableWatches;
560
+ /** The cached membership tables; see {@link readMembershipRows}. */
561
+ private readonly revocationView;
562
+ private readonly cadrePeerView;
563
+ private readonly ownerKeyView;
564
+ private readonly cadreInviteView;
565
+ private readonly cadreInviteUsageView;
566
+ private readonly ownerGrantView;
567
+ private readonly ownerGrantUsageView;
568
+ /** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
569
+ private readonly membershipViews;
523
570
  /**
524
571
  * Locked write bodies currently running (see {@link runWriteBody}); what
525
572
  * {@link readRowsOnce} consults to route an unlocked read around a write that has
@@ -546,6 +593,30 @@ export declare class ControlDatabase {
546
593
  * Initialize the database - load schema and register plugins
547
594
  */
548
595
  initialize(): Promise<void>;
596
+ /**
597
+ * Drop a membership view whenever its table changes: a commit made here, or a change this
598
+ * node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
599
+ * does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
600
+ * subscription cannot hear. {@link close} disposes it with the rest.
601
+ */
602
+ private watchMembershipViews;
603
+ /**
604
+ * {@link watchTables} on the membership tables: the seven the membership views copy
605
+ * (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
606
+ * `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
607
+ * read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
608
+ * reads can depend on and no list of them lives outside this class.
609
+ */
610
+ watchMembershipTables(onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch;
611
+ /**
612
+ * Empty every membership view, so the next read of each loads afresh and no load in
613
+ * flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
614
+ * a sibling becomes reachable (its identify on a new connection): a machine that was offline
615
+ * while that sibling committed learns of the commit only when it next reads the block from
616
+ * storage, and dropping the views makes the pass that starts then read now rather than after
617
+ * `MEMBERSHIP_VIEW_MAX_AGE_MS`.
618
+ */
619
+ invalidateMembershipViews(): void;
549
620
  private loadSchema;
550
621
  /** Apply the declared control schema, retrying transient cohort failures. */
551
622
  private applyControlSchema;
@@ -679,6 +750,39 @@ export declare class ControlDatabase {
679
750
  * under-lock callback seam is ever added, audit its read graph the same way.
680
751
  */
681
752
  private readRows;
753
+ /**
754
+ * {@link readRows}, also saying whether the attempt that answered took the committed-read
755
+ * path — which a {@link MembershipView} load needs, since it may not keep such rows (see
756
+ * {@link membershipView}).
757
+ */
758
+ private readRowsRouted;
759
+ /**
760
+ * A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
761
+ * funnel. Rows a committed read served are never kept: that read may initialize an untouched
762
+ * table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
763
+ * on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
764
+ * event would ever drop them.
765
+ */
766
+ private membershipView;
767
+ /**
768
+ * A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
769
+ * and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
770
+ * `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
771
+ * membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
772
+ * Callers build fresh result objects from them and never mutate them.
773
+ *
774
+ * Reads the table directly instead when a caller under the write lock (`retry: false`) has an
775
+ * explicit transaction open: such a read sees the transaction's own uncommitted rows, which
776
+ * the view does not hold, and a rollback fires no change event, so keeping them would cache
777
+ * rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
778
+ * this keeps one added later, or a spec's raw-handle transaction, correct.
779
+ *
780
+ * Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
781
+ * schema's CHECK constraints, which read inside the committing transaction, the redemption's
782
+ * one read of the held credential row ({@link queryCadreInvite},
783
+ * {@link queryOwnerGrant}), and the indexed usage counts.
784
+ */
785
+ private readMembershipRows;
682
786
  /**
683
787
  * Query all strands from the control database
684
788
  */
@@ -690,6 +794,14 @@ export declare class ControlDatabase {
690
794
  * validated invitee during provision-then-record formation.
691
795
  */
692
796
  queryStrand(strandId: string): Promise<StrandRow | null>;
797
+ /**
798
+ * {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
799
+ * resolves a bound invite's host strand with it and later records consent against that
800
+ * stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
801
+ */
802
+ queryStrandWithStamp(strandId: string): Promise<(StrandRow & {
803
+ StampId: string;
804
+ }) | null>;
693
805
  /**
694
806
  * Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
695
807
  * as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
@@ -735,6 +847,9 @@ export declare class ControlDatabase {
735
847
  *
736
848
  * Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
737
849
  * insert is refused by the schema whenever any physical row exists, so this must see one.
850
+ * Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
851
+ * physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
852
+ * one storage read.
738
853
  */
739
854
  hasOwnerKey(): Promise<boolean>;
740
855
  /**
@@ -742,9 +857,44 @@ export declare class ControlDatabase {
742
857
  * the party has none yet. Returns true if it inserted, false if an owner
743
858
  * key already existed (so a repeat `--owner` start is a no-op).
744
859
  *
860
+ * Before the insert, gives every control table its empty collection
861
+ * ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
862
+ * forks if another machine creates the same collection independently; before genesis the
863
+ * party has no owner, so no other machine can have been admitted and none can hold any of
864
+ * its collections. The order makes the step repeatable: a crash between it and the insert
865
+ * leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
866
+ * collections still missing, while a founder restart finds the row and commits nothing, so
867
+ * it can never write an empty second history beside a table another machine has since
868
+ * written. A failure propagates with nothing inserted.
869
+ *
745
870
  * @param label - the user's name for the key ({@link insertOwnerKey}).
746
871
  */
747
872
  ensureOwnerKey(key: string, label?: string): Promise<boolean>;
873
+ /**
874
+ * Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
875
+ * tree that has none, so a read of a table nobody has written is answered from this machine's
876
+ * storage instead of asking the block's cohort to prove the collection absent — a round trip
877
+ * per read on a party of several machines, and an error or a wait while they are offline.
878
+ * Joining machines get these collections the ordinary way, by reading them from the cohort.
879
+ *
880
+ * Under the write lock: the helper commits whatever is staged in those collections outside any
881
+ * SQL transaction, which is sound only while nothing else writes.
882
+ *
883
+ * Parties founded before this step, and tables a later control schema version adds, have no
884
+ * such collections, and nothing creates them later: the start-time schema apply runs on every
885
+ * machine, possibly alone, where committing one would be exactly the fork genesis avoids.
886
+ *
887
+ * NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
888
+ * 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
889
+ * transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
890
+ * 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
891
+ * node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
892
+ * geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
893
+ * (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
894
+ * `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
895
+ * founding would use too; do not build a private batching path.
896
+ */
897
+ private commitEmptyControlCollections;
748
898
  /**
749
899
  * Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
750
900
  *
@@ -783,9 +933,8 @@ export declare class ControlDatabase {
783
933
  *
784
934
  * @param retired - the retired `OwnerKey` stamps, when the caller has already read the
785
935
  * tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
786
- * signers); omitted, they are read here. Before the ledger marker is filed every
787
- * `Revocation` read is a cohort consult, so the sync passing its own set keeps the
788
- * membership refresh at one such read for the anchor.
936
+ * signers); omitted, they are read here. Both come from the same `Revocation` view
937
+ * ({@link readMembershipRows}), so passing the set saves only the filtering.
789
938
  */
790
939
  queryOwnerKeyRows(retry?: boolean, retired?: ReadonlySet<string>): Promise<OwnerKeyRow[]>;
791
940
  /**
@@ -800,12 +949,8 @@ export declare class ControlDatabase {
800
949
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
801
950
  * that seam to model the replicated live-row-plus-tombstone merge state.
802
951
  *
803
- * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
804
- * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
805
- * while `Revocation` has never been written every call consults the cohort about it, and
806
- * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
807
- * the exclusion into one statement would not help (it reads the same block), and caching
808
- * the set would hide a tombstone arriving by replication.
952
+ * Both tables come from their membership views ({@link readMembershipRows}), so a call after
953
+ * the first costs no storage read until one of the two tables changes.
809
954
  *
810
955
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
811
956
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -866,17 +1011,18 @@ export declare class ControlDatabase {
866
1011
  * guard. A read retrying there would sleep its backoff HOLDING the write lock
867
1012
  * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
868
1013
  * the write funnel already re-runs the body's reads when it re-runs the body. The
869
- * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
870
- * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
871
- * default retried path. Explicit per call rather than an ambient flag: unlocked reads
872
- * run concurrently with a locked body, so `this`-state cannot tell the two apart.
1014
+ * unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
1015
+ * read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
1016
+ * rather than an ambient flag: unlocked reads run concurrently with a locked body, so
1017
+ * `this`-state cannot tell the two apart. The same flag threads through
1018
+ * {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
873
1019
  */
874
1020
  private queryStampId;
875
1021
  /**
876
1022
  * `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
877
1023
  * row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
878
- * `CadrePeer` writers ({@link insertCadrePeer}, {@link reauthorizeCadrePeer}) go
879
- * straight to {@link queryStampId}.
1024
+ * `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
1025
+ * the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
880
1026
  */
881
1027
  queryCadrePeerStampId(peerId: string): Promise<string | null>;
882
1028
  /**
@@ -913,33 +1059,34 @@ export declare class ControlDatabase {
913
1059
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
914
1060
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
915
1061
  *
916
- * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
917
- * `Revocation` has never been written, this node does not hold its block, and Optimystic
918
- * consults a block's cohort on every read of a block it does not hold: 1 consult per
919
- * call (measured), each a round trip to every other member on a multi-machine party. The
920
- * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
921
- * at most once per read-repair window (10 s). Do not cache the set instead: a cache
922
- * cannot see a tombstone arriving by replication, so it would delay a revocation. The
923
- * row count only grows (append-only) but stays small while removals are rare; revisit if
1062
+ * Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
1063
+ * every change to the table, local or replicated into this node's storage, so a tombstone
1064
+ * arriving by replication is seen on the next call. The cost that matters is the storage
1065
+ * layer's, paid by a cold view's load: while `Revocation` has never been written, this node
1066
+ * does not hold its block, and Optimystic consults a block's cohort on every read of a block
1067
+ * it does not hold, each a round trip to every other member on a multi-machine party. The row
1068
+ * count only grows (append-only) but stays small while removals are rare; revisit if
924
1069
  * removals become routine and the scan itself shows up in a profile.
925
1070
  *
926
1071
  * An isolated node that has never received the `Revocation` block reads it as holding no
927
- * revocations instead of throwing — see {@link readRevokedStampRows}.
1072
+ * revocations instead of throwing — see {@link readRevocationRowsOrNone}.
928
1073
  *
929
1074
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
930
1075
  * under the write lock — see that method's note.
931
1076
  */
932
1077
  queryRevokedStamps(tableName: RevocableTable, retry?: boolean): Promise<Set<string>>;
933
1078
  /**
934
- * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
935
- * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1079
+ * The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
1080
+ * load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
936
1081
  * the block and could ask no other cohort member about it. Every other failure rethrows.
937
1082
  * The retry in {@link readRows} runs first, so a node whose connectivity returns within
938
- * the read budget still gets the real answer.
1083
+ * the read budget still gets the real answer. The empty answer is never installed in the
1084
+ * view (a failed load keeps nothing), so the next call asks again.
939
1085
  *
940
1086
  * Without this, a machine cut off from every other machine in its party before it ever
941
- * received the block — the block does not exist anywhere until the owner files the
942
- * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
1087
+ * received the block — a joining machine gets it only by reading it from the cohort, and
1088
+ * on a party founded before genesis created every control collection it exists nowhere
1089
+ * until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
943
1090
  * answer ANY membership, peer-record or device-token lookup, although it holds those
944
1091
  * rows locally.
945
1092
  *
@@ -958,12 +1105,12 @@ export declare class ControlDatabase {
958
1105
  *
959
1106
  * NOTE: an isolated node pays the whole read retry (up to
960
1107
  * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
961
- * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
962
- * node's lookups are seen exceeding the admission gate's 2 s deadline
963
- * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
964
- * fails `cohort-unreachable`.
1108
+ * answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
1109
+ * load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
1110
+ * admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
1111
+ * read when the first attempt fails `cohort-unreachable`.
965
1112
  */
966
- private readRevokedStampRows;
1113
+ private readRevocationRowsOrNone;
967
1114
  /**
968
1115
  * Every locally-held `CadreControl.Revocation` tombstone — identity triple, its
969
1116
  * `ReissuedAt` counter and the stored signer pair. Consumed by the cohort-growth re-issue
@@ -972,17 +1119,18 @@ export declare class ControlDatabase {
972
1119
  * `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
973
1120
  * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
974
1121
  *
975
- * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
976
- * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
977
- * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
978
- * admits no other row under `'Revocation'`.
1122
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
1123
+ * retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
1124
+ * `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
1125
+ * TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
979
1126
  *
980
1127
  * `retry: false` from a caller that reads under the write lock, for the reason on
981
1128
  * {@link queryCadrePeers}.
982
1129
  */
983
1130
  queryRevocations(retry?: boolean): Promise<RevocationRow[]>;
984
1131
  /**
985
- * Read a single peer's address record (the full `CadrePeer` row) by PeerId.
1132
+ * Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
1133
+ * `CadrePeer` membership view ({@link readMembershipRows}).
986
1134
  *
987
1135
  * Returns null when no row exists, or when the row's `StampId` is retired in
988
1136
  * `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
@@ -998,10 +1146,22 @@ export declare class ControlDatabase {
998
1146
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
999
1147
  * the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
1000
1148
  * the publisher signed.
1149
+ *
1150
+ * `retry: false` from the one caller that reads under the write lock,
1151
+ * {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
1152
+ */
1153
+ queryPeerRecord(peerId: string, retry?: boolean): Promise<PeerAddressRecord | null>;
1154
+ /**
1155
+ * One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
1156
+ * or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
1157
+ * and the two writers that decide over the row inside their locked body
1158
+ * ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
1159
+ * {@link queryCadrePeers}, and the same `retry` contract.
1001
1160
  */
1002
- queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null>;
1161
+ private findCadrePeerRow;
1003
1162
  /**
1004
- * Apply a peer's own self-signed address-record update to an existing row.
1163
+ * Apply a peer's own self-signed address-record update to its existing row: read the row
1164
+ * under the write lock, have `sign` build the record over it, and write that record.
1005
1165
  *
1006
1166
  * Authorization is carried entirely by the record: the `Sig` column (verified
1007
1167
  * by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
@@ -1010,6 +1170,21 @@ export declare class ControlDatabase {
1010
1170
  * exists. `PublicKey` is intentionally not in the SET list (it is immutable on
1011
1171
  * self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
1012
1172
  *
1173
+ * The read, the decision and the signature all run INSIDE the locked body, which is what
1174
+ * lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
1175
+ * re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
1176
+ * When this write loses that race the commit is refused as a lost update and re-run, and the
1177
+ * re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
1178
+ * signed before the race could no longer exceed — and signs past it. A record signed outside
1179
+ * the body would be re-presented unchanged and refused again, this time by the schema's
1180
+ * monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
1181
+ * (see {@link queryStampId}'s NOTE).
1182
+ *
1183
+ * Returns the record actually written, so the caller's bookkeeping describes the row rather
1184
+ * than the first record it signed; null, and nothing written, when no readable row exists:
1185
+ * the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
1186
+ * as absent every heartbeat, and must not refresh it).
1187
+ *
1013
1188
  * Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
1014
1189
  * is not: it only ever touches THIS node's own row (sole caller
1015
1190
  * `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
@@ -1022,8 +1197,11 @@ export declare class ControlDatabase {
1022
1197
  * row, and a self insert happens once at startup, so the wasted refresh is a single read
1023
1198
  * — cheaper than a conditional carve-out inside the shared writer. Only the repeating
1024
1199
  * path is worth exempting.
1200
+ *
1201
+ * @param peerId - the row to refresh: this node's own.
1202
+ * @param sign - builds the signed record over the row as read; called once per attempt.
1025
1203
  */
1026
- updateSelfPeerRecord(record: PeerAddressRecord): Promise<void>;
1204
+ updateSelfPeerRecord(peerId: string, sign: SelfRecordSigner): Promise<PeerAddressRecord | null>;
1027
1205
  /**
1028
1206
  * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
1029
1207
  *
@@ -1146,6 +1324,13 @@ export declare class ControlDatabase {
1146
1324
  * body, and a row whose stamp is already retired (held without its delete) is skipped rather
1147
1325
  * than tombstoned twice. Nothing to remove writes nothing and returns all false.
1148
1326
  *
1327
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
1328
+ * stamp and signature is minted inside the body, and the invitation keys come from the
1329
+ * caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
1330
+ * since collides on `Revocation`'s primary key — a constraint failure, never retried, as
1331
+ * before). A lost update after a sibling tree of the transaction already synced arrives as a
1332
+ * partial commit and stays declined.
1333
+ *
1149
1334
  * The caller decides which invitations the machine could use, from its plan
1150
1335
  * (`CadreNode.planMachineRemoval`).
1151
1336
  */
@@ -1180,6 +1365,28 @@ export declare class ControlDatabase {
1180
1365
  * @param memberPrivateKey - Optional private key for membership in closed strands
1181
1366
  */
1182
1367
  insertStrand(strandId: string, type: 'o' | 'c', ownerKey: string, signMessage: ControlSign, memberPrivateKey?: string): Promise<void>;
1368
+ /** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
1369
+ private signStrandInsert;
1370
+ /**
1371
+ * Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
1372
+ * row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
1373
+ * never a strand row without the key or invitation written beside it.
1374
+ *
1375
+ * Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
1376
+ * {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
1377
+ * invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
1378
+ * taken: signing may prompt the user, and the lock held across a prompt would stall every
1379
+ * other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
1380
+ *
1381
+ * No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
1382
+ * `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
1383
+ * `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
1384
+ *
1385
+ * A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
1386
+ * again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
1387
+ * stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
1388
+ */
1389
+ insertFoundingRows(rows: FoundingRows, ownerKey: string, signMessage: ControlSign): Promise<void>;
1183
1390
  /**
1184
1391
  * Delete a strand from the control database using an owner signature.
1185
1392
  *
@@ -1238,6 +1445,8 @@ export declare class ControlDatabase {
1238
1445
  * with the owner's private key, returning a base64url signature.
1239
1446
  */
1240
1447
  insertStrandPartyKey(strandId: string, privateKey: string, ownerKey: string, signMessage: ControlSign): Promise<void>;
1448
+ /** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
1449
+ private signStrandPartyKeyInsert;
1241
1450
  /**
1242
1451
  * Owner-signed removal of one `StrandPartyKey` row — the party's own membership
1243
1452
  * identity for that strand, stored nowhere else, so this is as destructive as
@@ -1405,32 +1614,44 @@ export declare class ControlDatabase {
1405
1614
  profile: NodeProfile | null;
1406
1615
  }, ownerKey: string, signMessage: ControlSign): Promise<boolean>;
1407
1616
  /**
1408
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
1409
- * rewrite `VouchOwner`/`VouchSig` under the owner branch of `CadrePeer.AuthorizedUpdate`
1410
- * (a signature over the SAME `'vouch'`-tagged digest {@link insertCadrePeer} builds), so
1411
- * the row is re-emitted as a fresh, broadcasting transaction.
1412
- *
1413
- * This is the write-while-alone re-replication primitive: a membership row that committed
1414
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it grows, by
1415
- * re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because the row
1416
- * already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness stamp
1417
- * and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact (the
1418
- * owner branch requires them kept: they are the machine's own claim) — so it is safe
1419
- * over a row whose peer has not self-published (`Sig` null); the caller must skip a row
1420
- * that already carries a self-`Sig` (that row is the owning peer's to refresh, and bumping
1421
- * `UpdatedAt` without re-signing would invalidate its self-signature).
1422
- *
1423
- * Signs over the row's CURRENT `StampId` (unchanged by the re-touch), read BEFORE the lock
1424
- * is taken. An absent row returns `false` WITHOUT notifying: nothing was written, so the
1425
- * membership snapshot cannot have changed.
1426
- *
1427
- * NOTE: that stamp read is outside the lock, same as {@link deleteGuardedRow}'s. A writer
1428
- * that removes the row in between makes the `update` match nothing, and this still returns
1429
- * `true` and notifies — harmless today (the sole caller, the write-while-alone drain, only
1430
- * logs the result, and a spurious notify just re-reads the member set), and a remove-then-
1431
- * re-add in the same window fails loudly instead, since the signature binds the retired
1432
- * stamp. If a caller ever acts on `true` as proof the row was written, fold the stamp read
1433
- * into the locked body.
1617
+ * Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
1618
+ * (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
1619
+ * `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
1620
+ * {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
1621
+ * transaction, or stands on a different owner's voucher.
1622
+ *
1623
+ * Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
1624
+ * committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
1625
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
1626
+ * the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
1627
+ * stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
1628
+ * (the owner branch requires them kept: they are the machine's own claim) — so it is safe
1629
+ * over a row whose peer has not self-published (`Sig` null), and this method itself skips a
1630
+ * row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
1631
+ * bumping `UpdatedAt` without re-signing would invalidate its self-signature).
1632
+ *
1633
+ * The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
1634
+ * bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
1635
+ * only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
1636
+ * writes whether or not the machine has self-published (the owner branch has no monotonic
1637
+ * rule, so the column simply keeps its value).
1638
+ *
1639
+ * The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
1640
+ * row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
1641
+ * what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
1642
+ * the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
1643
+ * at the moment that publish does. A re-run after losing to it reads the self-signed row and
1644
+ * reports `'self-published'` instead of writing the owner branch over it — which would rewrite
1645
+ * `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
1646
+ * resolvers until the machine's next heartbeat. The decision has to be the body's for that;
1647
+ * a caller's pre-check outside the lock is only a fast path (see below).
1648
+ *
1649
+ * An absent row — none, or its stamp retired, or a stampless row no writer produces — is
1650
+ * `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
1651
+ * writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
1652
+ * ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
1653
+ * in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
1654
+ * which only re-reads the member set.
1434
1655
  *
1435
1656
  * Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
1436
1657
  * VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
@@ -1453,12 +1674,13 @@ export declare class ControlDatabase {
1453
1674
  * VouchOwner/VouchSig) rather than inherit this rebinding.
1454
1675
  *
1455
1676
  * @param peerId - the membership row to re-touch.
1456
- * @param updatedAt - the strictly-increasing freshness stamp to write.
1677
+ * @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
1457
1678
  * @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
1458
- * @param signMessage - ed25519-signs the raw message bytes with that owner's private key.
1459
- * @returns `true` when the row was re-touched, `false` when no row exists (no notify).
1679
+ * @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
1680
+ * called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
1681
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}).
1460
1682
  */
1461
- reauthorizeCadrePeer(peerId: string, updatedAt: number, ownerKey: string, signMessage: ControlSign): Promise<boolean>;
1683
+ reauthorizeCadrePeer(peerId: string, mode: CadrePeerRetouchMode, ownerKey: string, signMessage: ControlSign): Promise<CadrePeerRetouchOutcome>;
1462
1684
  /**
1463
1685
  * Owner-signed removal of one `CadrePeer` membership row, notifying the membership
1464
1686
  * listener once it has committed.
@@ -1478,6 +1700,12 @@ export declare class ControlDatabase {
1478
1700
  *
1479
1701
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
1480
1702
  * when a row was actually removed.
1703
+ *
1704
+ * Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
1705
+ * at the moment of the removal (its first publish lands about a second after it starts), and
1706
+ * the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
1707
+ * and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
1708
+ * binds, so the re-run reads the published row and removes it.
1481
1709
  */
1482
1710
  deleteCadrePeer(peerId: string, ownerKey: string, signMessage: ControlSign): Promise<boolean>;
1483
1711
  /**
@@ -1539,9 +1767,8 @@ export declare class ControlDatabase {
1539
1767
  /**
1540
1768
  * File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
1541
1769
  * statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
1542
- * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays — or the ledger
1543
- * marker ({@link openRevocationLedger}), which retires nothing. The caller supplies the
1544
- * lock and, where one is needed, the transaction.
1770
+ * issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
1771
+ * supplies the lock and, where one is needed, the transaction.
1545
1772
  *
1546
1773
  * `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
1547
1774
  * than leaning on the column default — the seat-at-zero rule is load-bearing for
@@ -1579,9 +1806,10 @@ export declare class ControlDatabase {
1579
1806
  * statement, and without the clause the reap would delete the owner's brand-new row.
1580
1807
  * With it the statement matches nothing and the reap is a silent no-op (the schema
1581
1808
  * branch would also refuse that delete, but as a constraint error thrown into a
1582
- * background sweep — matched-nothing is the better failure mode). Like
1583
- * {@link reauthorizeCadrePeer}, a race lost AFTER the guard still returns `true`;
1584
- * harmless — nothing was deleted, and the next sweep re-reads.
1809
+ * background sweep — matched-nothing is the better failure mode). A race lost AFTER the
1810
+ * guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
1811
+ * its lock and reports what it found); harmless — nothing was deleted, and the next sweep
1812
+ * re-reads.
1585
1813
  *
1586
1814
  * Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
1587
1815
  * committed tombstone that authorizes the reap, so a reap files no second tombstone.
@@ -1608,7 +1836,7 @@ export declare class ControlDatabase {
1608
1836
  * present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
1609
1837
  *
1610
1838
  * Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
1611
- * reconcile pass runs while the node has at least one control connection.
1839
+ * reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
1612
1840
  */
1613
1841
  reapRevokedRow(table: ReapableTable, rowKey: string, stampId: string): Promise<boolean>;
1614
1842
  /**
@@ -1619,8 +1847,8 @@ export declare class ControlDatabase {
1619
1847
  * carry the delete (replaying a delete of an already-absent row is a no-op), so a node
1620
1848
  * that converged on a removal while holding the removed row keeps that row as inert
1621
1849
  * garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
1622
- * control-cohort reconcile pass is that something, gated on the node having at least one
1623
- * control connection (a reap is a write, and a write committed alone is local-only).
1850
+ * control-cohort reconcile pass is that something, gated on a sibling being reachable
1851
+ * (a reap is a write, and a write committed alone is local-only).
1624
1852
  *
1625
1853
  * Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
1626
1854
  * lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
@@ -1653,12 +1881,11 @@ export declare class ControlDatabase {
1653
1881
  *
1654
1882
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
1655
1883
  * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
1656
- * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
1657
- * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
1658
- * only while `Revocation` has never been written; the ledger marker
1659
- * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
1884
+ * the common case (a party that has never revoked anyone) a single scan per pass, served
1885
+ * from the `Revocation` membership view
1886
+ * ({@link readMembershipRows}) unless the table changed since the last read.
1660
1887
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
1661
- * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
1888
+ * lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
1662
1889
  * party; if the table ever grows, persist a node-local high-water mark of what has
1663
1890
  * already been reaped instead of re-scanning everything (the same bound the sweep in
1664
1891
  * `CadreNode.drainPendingRevocations` wants).
@@ -1693,44 +1920,6 @@ export declare class ControlDatabase {
1693
1920
  * Returns how many UPDATE statements ran (`rows.length` on success).
1694
1921
  */
1695
1922
  reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number, ownerKey: string, signMessage: ControlSign): Promise<number>;
1696
- /**
1697
- * Owner-signed filing of the singleton `Revocation` ledger marker
1698
- * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
1699
- *
1700
- * Why it exists: while `Revocation` has never been written, this node does not hold its
1701
- * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
1702
- * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
1703
- * reads this table. Once any row exists the block is held and re-checked at most once per
1704
- * read-repair window, like every other populated table. The schema's table comment says
1705
- * why the row can never read as a retirement.
1706
- *
1707
- * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
1708
- * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
1709
- * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
1710
- * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
1711
- * full composite primary key, which is served as a point lookup that can miss an existing
1712
- * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
1713
- *
1714
- * Two owners filing at once: the loser either sees the marker in its guard or is refused
1715
- * on the primary key, and both answer `'already-open'`.
1716
- *
1717
- * The caller decides WHEN. A marker committed while the node is alone is local-only and
1718
- * can fork the collection, so {@link CadreNode} files it only while connected; this
1719
- * method does not look at connectivity.
1720
- *
1721
- * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
1722
- * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
1723
- * private key, returning a base64url signature.
1724
- * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
1725
- * already there.
1726
- */
1727
- openRevocationLedger(ownerKey: string, signMessage: ControlSign): Promise<RevocationLedgerOpenResult>;
1728
- /**
1729
- * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
1730
- * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
1731
- * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
1732
- */
1733
- private revocationLedgerFiled;
1734
1923
  /**
1735
1924
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
1736
1925
  *
@@ -1762,8 +1951,21 @@ export declare class ControlDatabase {
1762
1951
  * {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
1763
1952
  * second call replaces the first rather than fanning out, so a stale node can
1764
1953
  * never keep receiving notifications for a database it no longer owns.
1954
+ *
1955
+ * Only this database's own writes notify here, awaited inside the write so the gate is
1956
+ * current before the write returns. A membership change replicated in from another
1957
+ * machine reaches the gate through the subscription `CadreNode.start` takes on the
1958
+ * membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
1959
+ * lands in storage; one storage never hears of waits for the reconcile pass's backstop.
1765
1960
  */
1766
1961
  setMembershipChangeListener(listener: MembershipChangeListener | null): void;
1962
+ /**
1963
+ * Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
1964
+ * made here and changes replicated into this node's storage alike, on the single-flight
1965
+ * schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
1966
+ * still live before it drains the write queue.
1967
+ */
1968
+ watchTables(tables: readonly string[], onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch;
1767
1969
  /**
1768
1970
  * Wire (or clear, with null) the single listener notified after every committed
1769
1971
  * guarded-table delete. Same ownership contract as
@@ -1818,8 +2020,11 @@ export declare class ControlDatabase {
1818
2020
  * exhaustion.
1819
2021
  *
1820
2022
  * @param reason - Label for the log line only (e.g. `'peer-insert'`).
2023
+ * @param policy - The retry policy, default unless the body has earned a wider one: the three
2024
+ * `CadrePeer` writers whose bodies re-read the row and decide inside pass
2025
+ * `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
1821
2026
  */
1822
- mutateCadrePeer<T>(reason: string, body: () => Promise<T>): Promise<T>;
2027
+ mutateCadrePeer<T>(reason: string, body: () => Promise<T>, policy?: ControlWriteRetryOptions): Promise<T>;
1823
2028
  /**
1824
2029
  * Run one local write under the database-wide write lock, serializing it against
1825
2030
  * every other local writer on this ControlDatabase.
@@ -1832,8 +2037,8 @@ export declare class ControlDatabase {
1832
2037
  * class: every public write method of this class runs its statement(s) under it, and
1833
2038
  * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
1834
2039
  * `Database` directly, so it is covered too. The race is
1835
- * real, not theoretical: the background self-record publish on a control connection
1836
- * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
2040
+ * real, not theoretical: the background self-record publish when a sibling becomes
2041
+ * reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
1837
2042
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
1838
2043
  * assert.
1839
2044
  *
@@ -1857,6 +2062,11 @@ export declare class ControlDatabase {
1857
2062
  * re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
1858
2063
  * Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
1859
2064
  * attempt: a retry re-presents the exact signed message the first attempt presented.
2065
+ * A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
2066
+ * its re-run is a re-decision over a row another writer changed, not a re-presentation of
2067
+ * the same write, so its read of the row, its decision and its signature all run in the
2068
+ * body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
2069
+ * under {@link deleteCadrePeer}, {@link removeMachine}).
1860
2070
  */
1861
2071
  withWriteLock<T>(fn: () => Promise<T>): Promise<T>;
1862
2072
  /**
@@ -1882,9 +2092,14 @@ export declare class ControlDatabase {
1882
2092
  * `initialized` is set — so it reaches this method directly rather than through
1883
2093
  * {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
1884
2094
  * necessarily empty at that point, so the lock is uncontended there; it is taken anyway
1885
- * rather than carving out a lockless path. It is also the ONLY caller that passes a
1886
- * `policy` — everything else takes the default, whose classifier and attempt count are
1887
- * unchanged by that call site existing.
2095
+ * rather than carving out a lockless path.
2096
+ *
2097
+ * Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
2098
+ * call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
2099
+ * row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
2100
+ * {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
2101
+ * policy parameter). Everything else takes the default, whose classifier and attempt count
2102
+ * are unchanged by those call sites existing.
1888
2103
  *
1889
2104
  * `label` names the operation in this loop's debug lines and nothing else
1890
2105
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
@@ -1987,6 +2202,11 @@ export declare class ControlDatabase {
1987
2202
  validationUrl?: string;
1988
2203
  strandId?: string;
1989
2204
  }): Promise<void>;
2205
+ /**
2206
+ * The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
2207
+ * {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
2208
+ */
2209
+ private signFormationInviteInsert;
1990
2210
  /**
1991
2211
  * Redeem a `FormationInvite` by inserting the `Strand` row and a matching
1992
2212
  * `FormationUsage` row **atomically, in one transaction**.
@@ -2057,14 +2277,13 @@ export declare class ControlDatabase {
2057
2277
  * Use {@link redeemInvitation} instead when the strand must be created by
2058
2278
  * consent atomically with the usage.
2059
2279
  *
2060
- * The strand's live `StampId` is read first and written onto the usage row:
2280
+ * The strand's live `StampId` is written onto the usage row:
2061
2281
  * `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
2062
2282
  * `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
2063
- * record authorizes exactly the strand ROW it was recorded against. A missing strand
2064
- * THROWS here rather than being left to the deferred `StrandExists` CHECK — the
2065
- * ordinary "host strand has not converged yet" case is already reported as `missing`
2066
- * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
2067
- * point is a genuine race and deserves a named error, not a silent rollback.
2283
+ * record authorizes exactly the strand ROW it was recorded against. A caller that already
2284
+ * read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
2285
+ * missing strand THROWS {@link MissingHostStrandError} rather than being left to the
2286
+ * deferred `StrandExists` CHECK.
2068
2287
  *
2069
2288
  * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
2070
2289
  * path — the one production actually races on (a bound invite published by `cadre-web` /
@@ -2099,6 +2318,13 @@ export declare class ControlDatabase {
2099
2318
  * {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
2100
2319
  */
2101
2320
  totalUses?: number | null;
2321
+ /**
2322
+ * The host strand's `StampId`, when the caller already read the row
2323
+ * ({@link queryStrandWithStamp}); `undefined` reads it here. A stamp is fixed for the life
2324
+ * of its row, so one read earlier is the stamp `StrandExists` compares, unless the row was
2325
+ * removed (and re-seated) in between, which that CHECK refuses at commit.
2326
+ */
2327
+ strandStampId?: string;
2102
2328
  }): Promise<FormationUsageResult>;
2103
2329
  /**
2104
2330
  * Refuse a redemption that would consume a seat the invite does not have.
@@ -2152,7 +2378,7 @@ export declare class ControlDatabase {
2152
2378
  * permissive PRE-check that runs ahead of it, and a transiently short read costs each of
2153
2379
  * them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
2154
2380
  * {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
2155
- * generic refusal; `ControlFormationUsageRecorder.isTokenUsed` reports not-used and lets
2381
+ * generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
2156
2382
  * the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
2157
2383
  * holds the stranger-admission door open slightly longer.
2158
2384
  *
@@ -2178,12 +2404,12 @@ export declare class ControlDatabase {
2178
2404
  /**
2179
2405
  * Is any `FormationInvite` row still redeemable — unexpired AND with usage
2180
2406
  * below its `TotalUses`? A null `ExpiresAt` never expires and a null
2181
- * `TotalUses` is unlimited, matching {@link ControlFormationUsageRecorder}'s
2182
- * per-token semantics (`isTokenValid` / `isTokenUsed`).
2407
+ * `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
2408
+ * (`formation-token.ts`).
2183
2409
  *
2184
2410
  * Answers the control-network connection gate's coarse "does this node expect
2185
2411
  * a stranger?" question, which has no token to ask about. The expiry
2186
- * comparison is `expiresAtMs <= now` — identical to `isTokenValid`'s — so an
2412
+ * comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
2187
2413
  * invite the formation handler would reject can never hold the gate open.
2188
2414
  *
2189
2415
  * The scan is deliberately not pushed into SQL: nothing else here compares a
@@ -2263,8 +2489,10 @@ export declare class ControlDatabase {
2263
2489
  * the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
2264
2490
  * question, and `CadreInviteUsage.Authorized`'s at redemption.
2265
2491
  *
2266
- * `retry: false` is passed by {@link redeemCadreInvite}, which reads INSIDE its locked write
2267
- * body — the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
2492
+ * A redemption reads the row once, before its write, and carries it into
2493
+ * {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
2494
+ * {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
2495
+ * the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
2268
2496
  */
2269
2497
  queryCadreInvite(key: string, retry?: boolean): Promise<CadreInviteRow | null>;
2270
2498
  /**
@@ -2278,7 +2506,7 @@ export declare class ControlDatabase {
2278
2506
  /**
2279
2507
  * Every `CadreInviteUsage` row this node holds — the redemption records the membership
2280
2508
  * predicate verifies invitation-admitted rows against. Same `retry` contract as
2281
- * {@link queryCadreInvites}.
2509
+ * {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
2282
2510
  */
2283
2511
  queryCadreInviteUsages(retry?: boolean): Promise<CadreInviteUsageRow[]>;
2284
2512
  /**
@@ -2306,14 +2534,19 @@ export declare class ControlDatabase {
2306
2534
  hasLiveCadreInvite(nowMs?: number): Promise<boolean>;
2307
2535
  /**
2308
2536
  * Is this one cadre invitation still redeemable here — the conditions
2309
- * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this node
2310
- * holds under `invite.key`; when it holds none, `invite` itself (a bundle's copy, not seated
2311
- * yet), but only if its issuer signature verifies, because an unsigned copy would otherwise be
2312
- * judged by whichever owner it names as issuer. A permissive pre-check like
2313
- * {@link countCadreInviteUsage}: {@link seatCadreInvite} and the redemption's own write are
2314
- * what enforce every condition.
2537
+ * {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
2538
+ * node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
2539
+ * none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
2540
+ * verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
2541
+ * issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
2542
+ * and the redemption's own write are what enforce every condition.
2543
+ *
2544
+ * The use count read here is read again inside the redemption's write lock
2545
+ * ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
2546
+ * pushed the control store before the write, so the holder of a spent invitation is refused
2547
+ * without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
2315
2548
  */
2316
- isCadreInviteLive(invite: CadreInviteRow, nowMs?: number): Promise<boolean>;
2549
+ isCadreInviteLive(invite: CadreInviteRow, held: CadreInviteRow | null, nowMs?: number): Promise<boolean>;
2317
2550
  /**
2318
2551
  * The body of {@link isCadreInviteLive} and {@link isOwnerGrantLive}: is `row` (the one held,
2319
2552
  * or a verified bundle copy; null for neither) still open and under its use cap?
@@ -2394,11 +2627,27 @@ export declare class ControlDatabase {
2394
2627
  */
2395
2628
  redeemCadreInvite(params: {
2396
2629
  inviteKey: string;
2630
+ /**
2631
+ * The row this node holds under `inviteKey`, read before the call ({@link queryCadreInvite});
2632
+ * omitted, it is read inside the locked body. Never a bundle's copy: the requester chooses a
2633
+ * copy's `TotalUses` and `IssuerKey`, and the seat check must judge the stored cap. The
2634
+ * schema refuses every update and delete of the row (`NoUpdate`, `NoDelete`), so the earlier
2635
+ * read is the row a locked read would return; withdrawal is a `Revocation` row, which the schema's
2636
+ * `NotRevoked` and `Authorized` checks still read at commit.
2637
+ */
2638
+ held?: CadreInviteRow;
2397
2639
  peerId: string;
2398
2640
  /** The device's ed25519 public key (base64url); `peerId` is its identity multihash. */
2399
2641
  peerKey: string;
2400
2642
  /** The device's dialable addresses, if known; stored comma-joined as `authorizePeer` does. */
2401
2643
  multiaddrs?: string[];
2644
+ /**
2645
+ * The rest of the device's own signed address record over `multiaddrs`, already verified by
2646
+ * the caller (`admittedRecord` in `cadre-invite-protocol.ts`): stored as the row's
2647
+ * `UpdatedAt`, `Sig` and `Profile`, so the row resolves to the device's addresses from its
2648
+ * admission. Omitted, the row is written unsigned, `UpdatedAt` at this machine's clock.
2649
+ */
2650
+ record?: Pick<PeerAddressRecord, 'updatedAt' | 'profile' | 'sig'>;
2402
2651
  /** Single-use nonce minted by the device; both signatures cover it. */
2403
2652
  usageStampId: string;
2404
2653
  /** The holder's signature with the invitation private key over the `'redeem'` digest. */
@@ -2458,7 +2707,9 @@ export declare class ControlDatabase {
2458
2707
  /**
2459
2708
  * Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
2460
2709
  * as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
2461
- * away. `retry: false` is passed by {@link redeemOwnerGrant}, inside its locked body.
2710
+ * away. Read once per redemption and carried into {@link isOwnerGrantLive} and
2711
+ * {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
2712
+ * handed none and reads inside its locked body.
2462
2713
  */
2463
2714
  queryOwnerGrant(key: string, retry?: boolean): Promise<OwnerGrantRow | null>;
2464
2715
  /**
@@ -2467,7 +2718,7 @@ export declare class ControlDatabase {
2467
2718
  * `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
2468
2719
  */
2469
2720
  queryOwnerGrants(retry?: boolean): Promise<OwnerGrantRow[]>;
2470
- /** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
2721
+ /** 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}). */
2471
2722
  queryOwnerGrantUsages(retry?: boolean): Promise<OwnerGrantUsageRow[]>;
2472
2723
  /**
2473
2724
  * How many times an owner grant has been redeemed (its `OwnerGrantUsage` rows), served
@@ -2477,12 +2728,14 @@ export declare class ControlDatabase {
2477
2728
  countOwnerGrantUsage(key: string, retry?: boolean): Promise<number>;
2478
2729
  /**
2479
2730
  * Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
2480
- * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row this
2481
- * node holds under `grant.key`, else by `grant` itself (a bundle's copy, not seated yet)
2482
- * when its issuer signature verifies. A permissive pre-check; {@link seatOwnerGrant} and the
2483
- * redemption's own write enforce every condition.
2731
+ * still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
2732
+ * this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
2733
+ * `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
2734
+ * permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
2735
+ * condition. Its use count is read again inside the write lock, for
2736
+ * {@link isCadreInviteLive}'s reason.
2484
2737
  */
2485
- isOwnerGrantLive(grant: OwnerGrantRow, nowMs?: number): Promise<boolean>;
2738
+ isOwnerGrantLive(grant: OwnerGrantRow, held: OwnerGrantRow | null, nowMs?: number): Promise<boolean>;
2486
2739
  /**
2487
2740
  * Every `OwnerGrant` row this node holds with its standing — recorded redemptions, withdrawal,
2488
2741
  * and whether it is still redeemable — for an owner's listing, as {@link listCadreInviteStatuses}.
@@ -2534,6 +2787,8 @@ export declare class ControlDatabase {
2534
2787
  */
2535
2788
  redeemOwnerGrant(params: {
2536
2789
  grantKey: string;
2790
+ /** The row this node holds under `grantKey`, read before the call; omitted, it is read inside the locked body. Never a bundle's copy, for {@link redeemCadreInvite}'s reason. */
2791
+ held?: OwnerGrantRow;
2537
2792
  /** The owner key to seat (base64url ed25519): the holder generated it, or already holds it. */
2538
2793
  seatedKey: string;
2539
2794
  /** Single-use nonce minted by the holder; both signatures cover it. */
@@ -2549,7 +2804,10 @@ export declare class ControlDatabase {
2549
2804
  /**
2550
2805
  * Close the database and cleanup resources.
2551
2806
  *
2552
- * Drains the local-write chain first: {@link withWriteLock} can park a write behind
2807
+ * Disposes every {@link watchTables} subscription before anything else, so a commit that
2808
+ * lands while the write chain drains schedules no run, and again before dropping the handle.
2809
+ *
2810
+ * Then drains the local-write chain: {@link withWriteLock} can park a write behind
2553
2811
  * others across an await, and a queued closure evaluates `this.db!` only when it
2554
2812
  * finally runs — so nulling the handle out from under it would throw a TypeError on a
2555
2813
  * null handle instead of committing. The tail never rejects (it swallows both