@serfab/cadre-core 0.11.0 → 0.13.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 (121) hide show
  1. package/README.md +3 -2
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +607 -53
  6. package/dist/cadre-node.js +1268 -157
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-database.d.ts +174 -23
  9. package/dist/control-database.js +317 -107
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +103 -0
  12. package/dist/control-read-retry.js +145 -0
  13. package/dist/control-read-retry.js.map +1 -0
  14. package/dist/control-retry.d.ts +101 -0
  15. package/dist/control-retry.js +108 -0
  16. package/dist/control-retry.js.map +1 -0
  17. package/dist/control-schema.d.ts +1 -1
  18. package/dist/control-schema.js +50 -6
  19. package/dist/control-schema.js.map +1 -1
  20. package/dist/control-write-retry.d.ts +59 -35
  21. package/dist/control-write-retry.js +21 -92
  22. package/dist/control-write-retry.js.map +1 -1
  23. package/dist/delegate-admission.d.ts +17 -15
  24. package/dist/delegate-admission.js +17 -15
  25. package/dist/delegate-admission.js.map +1 -1
  26. package/dist/enrolled-machine-store-file.d.ts +13 -0
  27. package/dist/enrolled-machine-store-file.js +41 -0
  28. package/dist/enrolled-machine-store-file.js.map +1 -0
  29. package/dist/enrolled-machine-store.d.ts +81 -0
  30. package/dist/enrolled-machine-store.js +225 -0
  31. package/dist/enrolled-machine-store.js.map +1 -0
  32. package/dist/file-durable-slot.js +5 -3
  33. package/dist/file-durable-slot.js.map +1 -1
  34. package/dist/index.d.ts +10 -6
  35. package/dist/index.js +39 -12
  36. package/dist/index.js.map +1 -1
  37. package/dist/membership-connection-gater.d.ts +226 -26
  38. package/dist/membership-connection-gater.js +316 -30
  39. package/dist/membership-connection-gater.js.map +1 -1
  40. package/dist/node-local-snapshot.d.ts +6 -0
  41. package/dist/node-local-snapshot.js +7 -1
  42. package/dist/node-local-snapshot.js.map +1 -1
  43. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  44. package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
  45. package/dist/peer-join-backfill.js.map +1 -0
  46. package/dist/peer-record.d.ts +26 -0
  47. package/dist/peer-record.js +32 -0
  48. package/dist/peer-record.js.map +1 -1
  49. package/dist/relay-addrs.d.ts +145 -22
  50. package/dist/relay-addrs.js +314 -31
  51. package/dist/relay-addrs.js.map +1 -1
  52. package/dist/relay-reservation.d.ts +20 -15
  53. package/dist/relay-reservation.js +18 -14
  54. package/dist/relay-reservation.js.map +1 -1
  55. package/dist/strand-addr-protocol.js +16 -0
  56. package/dist/strand-addr-protocol.js.map +1 -1
  57. package/dist/strand-database.d.ts +12 -0
  58. package/dist/strand-database.js +16 -0
  59. package/dist/strand-database.js.map +1 -1
  60. package/dist/strand-formation-manager.d.ts +22 -9
  61. package/dist/strand-formation-manager.js +20 -12
  62. package/dist/strand-formation-manager.js.map +1 -1
  63. package/dist/strand-formation-protocol.d.ts +83 -9
  64. package/dist/strand-formation-protocol.js +86 -10
  65. package/dist/strand-formation-protocol.js.map +1 -1
  66. package/dist/strand-instance-manager.d.ts +151 -17
  67. package/dist/strand-instance-manager.js +237 -32
  68. package/dist/strand-instance-manager.js.map +1 -1
  69. package/dist/strand-membership-writer.d.ts +118 -14
  70. package/dist/strand-membership-writer.js +203 -15
  71. package/dist/strand-membership-writer.js.map +1 -1
  72. package/dist/strand-network-config.d.ts +84 -0
  73. package/dist/strand-network-config.js +136 -0
  74. package/dist/strand-network-config.js.map +1 -0
  75. package/dist/strand-revocation-enforcer.d.ts +392 -0
  76. package/dist/strand-revocation-enforcer.js +533 -0
  77. package/dist/strand-revocation-enforcer.js.map +1 -0
  78. package/dist/strand-solicitation.d.ts +10 -3
  79. package/dist/strand-solicitation.js +12 -7
  80. package/dist/strand-solicitation.js.map +1 -1
  81. package/dist/strand-wake-protocol.d.ts +47 -2
  82. package/dist/strand-wake-protocol.js +76 -6
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/strand-watcher.js +6 -1
  85. package/dist/strand-watcher.js.map +1 -1
  86. package/dist/types.d.ts +282 -43
  87. package/dist/types.js +7 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +16 -8
  90. package/src/announce-addrs.ts +93 -0
  91. package/src/cadre-node.ts +1366 -171
  92. package/src/control-database.ts +2628 -2406
  93. package/src/control-read-retry.ts +167 -0
  94. package/src/control-retry.ts +190 -0
  95. package/src/control-schema.ts +50 -6
  96. package/src/control-write-retry.ts +30 -130
  97. package/src/delegate-admission.ts +17 -15
  98. package/src/enrolled-machine-store-file.ts +43 -0
  99. package/src/enrolled-machine-store.ts +280 -0
  100. package/src/file-durable-slot.ts +5 -3
  101. package/src/index.ts +477 -407
  102. package/src/membership-connection-gater.ts +539 -204
  103. package/src/node-local-snapshot.ts +13 -1
  104. package/src/peer-addr-book.ts +251 -250
  105. package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
  106. package/src/peer-record.ts +33 -0
  107. package/src/relay-addrs.ts +436 -111
  108. package/src/relay-reservation.ts +828 -823
  109. package/src/strand-addr-protocol.ts +17 -0
  110. package/src/strand-database.ts +17 -0
  111. package/src/strand-formation-manager.ts +511 -491
  112. package/src/strand-formation-protocol.ts +808 -692
  113. package/src/strand-instance-manager.ts +899 -611
  114. package/src/strand-membership-writer.ts +232 -15
  115. package/src/strand-network-config.ts +172 -0
  116. package/src/strand-revocation-enforcer.ts +653 -0
  117. package/src/strand-solicitation.ts +484 -472
  118. package/src/strand-wake-protocol.ts +94 -8
  119. package/src/strand-watcher.ts +319 -314
  120. package/src/types.ts +1554 -1309
  121. package/dist/strand-backfill.js.map +0 -1
@@ -9,6 +9,8 @@ import { canonicalDatetime } from './canonical-datetime.js';
9
9
  import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
10
10
  import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
11
11
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
12
+ import { retryControlRead } from './control-read-retry.js';
13
+ import { chainMessages } from './control-retry.js';
12
14
  const log = debug('sereus:cadre:control-db');
13
15
  const timing = debug('sereus:cadre:timing');
14
16
  /**
@@ -209,6 +211,36 @@ const GUARDED_KEY_COLUMN = {
209
211
  Strand: 'Id',
210
212
  DeviceToken: 'PeerId',
211
213
  };
214
+ /**
215
+ * The rejection a second insert of an already-seated strand id produces, as the optimystic
216
+ * vtab words it (`uniqueConstraintMessage`, qualified by table name only — no schema
217
+ * prefix, matching `test/control-constraint-helpers.ts`'s `expectUniqueViolation`).
218
+ *
219
+ * `Strand.StampId` is unique too, but a fresh stamp is minted per insert attempt, so only
220
+ * the primary key can collide on a repeat publish. Matching the column explicitly keeps
221
+ * any OTHER uniqueness failure out of the idempotency branch.
222
+ */
223
+ const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
224
+ /**
225
+ * Did this write fail because the strand id is already seated?
226
+ *
227
+ * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
228
+ * landed" (re-read, and no-op when the row matches) apart from every other rejection —
229
+ * an unauthorized signer, a retired stamp — which must keep surfacing.
230
+ *
231
+ * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
232
+ * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
233
+ * Fails CLOSED — a rewording upstream turns the idempotent branch back into the raw
234
+ * uniqueness error the caller saw before, never into a silent overwrite. The
235
+ * `publish-strand.spec.ts` repeat-publish cases assert against the live engine error, so a
236
+ * reword reddens there.
237
+ */
238
+ export function isStrandIdConflict(error) {
239
+ if (!(error instanceof Error)) {
240
+ return false;
241
+ }
242
+ return chainMessages(error).some(message => STRAND_ID_CONFLICT.test(message));
243
+ }
212
244
  /**
213
245
  * Guarded tables a node may reap locally once their tombstone has committed — the
214
246
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
@@ -248,6 +280,13 @@ export class ControlDatabase {
248
280
  * having to restate the policy's attempts or classifier.
249
281
  */
250
282
  this.controlWriteRetryPacing = {};
283
+ /**
284
+ * Pacing seams for {@link readRows}'s retry — the read twin of
285
+ * {@link controlWriteRetryPacing}, with the same contract: production leaves this
286
+ * empty, specs inject a recorded `sleep` / fake `now` so no test waits out a real
287
+ * backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
288
+ */
289
+ this.controlReadRetryPacing = {};
251
290
  this.config = config;
252
291
  }
253
292
  /**
@@ -396,17 +435,131 @@ export class ControlDatabase {
396
435
  await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
397
436
  log('Schema loaded and executed');
398
437
  }
438
+ /**
439
+ * One UNRETRIED drain of a control read to rows — every read in this class bottoms out
440
+ * here (almost all via {@link readRows}, which adds the transient-failure retry), and
441
+ * this is the ONE place the committed-read opt-in is spelled.
442
+ *
443
+ * Reached only through {@link readRows}, which drops straight here for the call sites
444
+ * that pass `retry: false` — reads issued from INSIDE a locked write body, where a
445
+ * backoff sleep would hold the write lock and the write funnel re-runs the read anyway
446
+ * (see the NOTE on {@link readRows}).
447
+ *
448
+ * A control read normally queues on the database's exec mutex, so it does not answer
449
+ * until whatever statement holds that mutex finishes. When a control WRITE is parked
450
+ * against an unresponsive cohort member that is minutes, and a read that only wants
451
+ * the last committed state waits the whole time for no reason
452
+ * (`complete/control-reads-blocked-by-stalled-write`).
453
+ *
454
+ * Quereus's remedy is `readConcurrency: 'committed'` — a PER-CALL opt-in
455
+ * (`Statement.tryRouteConcurrent` refuses anything that did not ask for it by name)
456
+ * that runs an eligible read off the mutex against each table's last committed state.
457
+ * Routing is best-effort and never an error: an ineligible statement falls back to
458
+ * the serialized path silently — any side-effecting node, any table-valued function
459
+ * call (pure ones included), any table whose module does not declare
460
+ * `readCommittedSnapshot`, or an EXPLICIT transaction being open on this database.
461
+ *
462
+ * **It is asked for only while a write is in flight, and that condition is the whole
463
+ * point.** The opt-in does not merely move the read off the mutex: it connects the
464
+ * table with `_readCommitted`, and the optimystic vtab serves such a connection from
465
+ * a pinned pre-transaction snapshot that NEVER refreshes from the network, where an
466
+ * ordinary read calls `update()` on the collection first. Taking that path
467
+ * unconditionally therefore trades "reads block behind a stalled write" for "reads
468
+ * stop seeing what other machines wrote" — measured, not theorised: with every read
469
+ * opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
470
+ * publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
471
+ *
472
+ * `getAutocommit()` reports the whole `Database` rather than this call (the same
473
+ * property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
474
+ * false means some writer's transaction is open, which is the only situation where a
475
+ * read would have blocked. The check races that writer harmlessly in both directions
476
+ * — losing it either reads normally (today's behaviour) or reads committed state.
477
+ *
478
+ * What a committed read gives up is observing a write that has not finished
479
+ * committing, and that is safe for every caller here: an AWAITED write is committed by
480
+ * the time it returns, and a read inside {@link inTransaction} is disqualified by the
481
+ * open explicit transaction and stays serialized, so it still sees its own
482
+ * transaction's rows. A read after an UNAWAITED write on this database would be wrong
483
+ * — no such caller exists, and {@link withWriteLock} is what keeps it that way.
484
+ */
485
+ async readRowsOnce(sql, params) {
486
+ let iterator;
487
+ if (this.db.getAutocommit()) {
488
+ iterator = this.db.eval(sql, params);
489
+ }
490
+ else {
491
+ log('read-eval: a write is in flight, asking for a committed read: %s', sql);
492
+ iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
493
+ }
494
+ const rows = [];
495
+ for await (const row of iterator) {
496
+ rows.push(row);
497
+ }
498
+ return rows;
499
+ }
500
+ /**
501
+ * {@link readRowsOnce} plus the bounded transient-failure retry
502
+ * ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
503
+ * read that failed because the cluster could not be asked properly (a stream reset
504
+ * mid-scan, a partially unreachable cohort) is re-presented a moment later instead of
505
+ * surfacing to every reader.
506
+ *
507
+ * MATERIALIZES the read rather than returning a lazy iterator, and that shape is what
508
+ * makes the retry possible at all: the failure happens during ITERATION, not at call
509
+ * time, and a half-consumed iterator cannot be retried without re-yielding rows the
510
+ * caller already saw. Materializing is safe here — every caller either drains to an
511
+ * array anyway or takes the first row of a statement that yields at most one (a
512
+ * primary-key lookup or a `count(1)`); nothing streams.
513
+ *
514
+ * Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
515
+ * (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
516
+ * and change the right answer.
517
+ *
518
+ * The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
519
+ * under the write funnel's: the tightest caller deadline over a control read is the
520
+ * inbound admission gate's 2 s FAIL-OPEN timeout, and a read that outlives it spends its
521
+ * retries after the gate has already admitted (see `control-read-retry.ts`).
522
+ *
523
+ * `label` names the read in the retry loop's `Control read [<label>] …` debug lines and
524
+ * nothing else; several reads are in flight concurrently in a real party, so an
525
+ * unlabelled line cannot be attributed — keep new call sites labelled.
526
+ *
527
+ * `retry: false` drops straight to {@link readRowsOnce}: reads issued from INSIDE a
528
+ * locked write body must not retry on their own. `retryControlWrite`'s contract is that
529
+ * backoff sleeps happen with NO lock held; a read retrying its own backoff inside a
530
+ * locked body would sleep holding the write lock (stalling every other local writer),
531
+ * and the write funnel already re-runs the body's reads when it re-runs the body. See
532
+ * {@link queryStampId} / {@link assertSeatRemains} / {@link queryCadrePeers} for the
533
+ * per-call opt-outs.
534
+ *
535
+ * NOTE: nothing structural enforces that opt-out — it is per-call because an unlocked
536
+ * read runs CONCURRENTLY with a locked body, so `this`-state cannot tell the two apart,
537
+ * and there is no async-context primitive available on every target platform (browser,
538
+ * React Native) to carry the answer. Reads reached through a CALLBACK invoked under the
539
+ * lock are the easy miss: `notifyMembershipChanged` runs its listener with the lock
540
+ * held, and its membership read is opted out at {@link queryCadrePeers}. If another
541
+ * under-lock callback seam is ever added, audit its read graph the same way.
542
+ */
543
+ readRows(sql, params, label, retry = true) {
544
+ if (!retry) {
545
+ return this.readRowsOnce(sql, params);
546
+ }
547
+ // The label lands LAST so the call site's name survives the spec-injected pacing —
548
+ // it changes log attribution only, never behaviour (same layering as lockedWithRetry).
549
+ return retryControlRead(() => this.readRowsOnce(sql, params), { ...this.controlReadRetryPacing, label });
550
+ }
399
551
  /**
400
552
  * Query all strands from the control database
401
553
  */
402
554
  async queryStrands() {
403
555
  this.ensureInitialized();
404
556
  const results = [];
405
- for await (const row of this.db.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand')) {
557
+ for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand', undefined, 'strands')) {
406
558
  results.push({
407
559
  Id: row.Id,
408
560
  MemberPrivateKey: row.MemberPrivateKey,
409
561
  Type: row.Type,
562
+ FounderOwnerKey: row.FounderOwnerKey,
410
563
  });
411
564
  }
412
565
  return results;
@@ -419,11 +572,12 @@ export class ControlDatabase {
419
572
  */
420
573
  async queryStrand(strandId) {
421
574
  this.ensureInitialized();
422
- for await (const row of this.db.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?', [strandId])) {
575
+ for (const row of await this.readRows('select Id, MemberPrivateKey, Type, FounderOwnerKey from CadreControl.Strand where Id = ?', [strandId], 'strand')) {
423
576
  return {
424
577
  Id: row.Id,
425
578
  MemberPrivateKey: row.MemberPrivateKey,
426
579
  Type: row.Type,
580
+ FounderOwnerKey: row.FounderOwnerKey,
427
581
  };
428
582
  }
429
583
  return null;
@@ -444,7 +598,7 @@ export class ControlDatabase {
444
598
  if (!CONTROL_TABLE_SET.has(table)) {
445
599
  throw new Error(`Unknown CadreControl table: ${table}`);
446
600
  }
447
- for await (const row of this.db.eval(`select count(1) as Count from CadreControl.${table}`)) {
601
+ for (const row of await this.readRows(`select count(1) as Count from CadreControl.${table}`, undefined, 'count-rows')) {
448
602
  return row.Count ?? 0;
449
603
  }
450
604
  return 0;
@@ -462,7 +616,7 @@ export class ControlDatabase {
462
616
  */
463
617
  async hasOwnerKey() {
464
618
  this.ensureInitialized();
465
- for await (const row of this.db.eval('select count(1) as Count from CadreControl.OwnerKey')) {
619
+ for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
466
620
  return row.Count > 0;
467
621
  }
468
622
  return false;
@@ -493,7 +647,7 @@ export class ControlDatabase {
493
647
  async getOwnerKeys() {
494
648
  this.ensureInitialized();
495
649
  const keys = new Set();
496
- for await (const row of this.db.eval('select Key from CadreControl.OwnerKey')) {
650
+ for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
497
651
  keys.add(row.Key);
498
652
  }
499
653
  return keys;
@@ -522,12 +676,21 @@ export class ControlDatabase {
522
676
  * carried into both, or those suites keep passing against a contract the database no
523
677
  * longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
524
678
  * second dimension, give the fakes a shared fixture or a contract test instead.
679
+ *
680
+ * `retry: false` is passed by exactly one caller —
681
+ * `CadreNode.refreshAuthorizedControlPeers`, the membership-gate snapshot refresh —
682
+ * because {@link notifyMembershipChanged} invokes that listener with the write lock
683
+ * HELD, and a retrying read there would sleep its backoff holding the lock (see the
684
+ * NOTE on {@link readRows}). It costs nothing: that refresh is best-effort, keeps the
685
+ * previous snapshot on failure, and is re-driven by the next write and by the timed
686
+ * reconcile. BOTH of this method's reads take the flag — retrying either one under the
687
+ * lock is the same defect.
525
688
  */
526
- async queryCadrePeers() {
689
+ async queryCadrePeers(retry = true) {
527
690
  this.ensureInitialized();
528
- const revoked = await this.queryRevokedStamps('CadrePeer');
691
+ const revoked = await this.queryRevokedStamps('CadrePeer', retry);
529
692
  const rows = [];
530
- for await (const row of this.db.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
693
+ for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
531
694
  const stampId = row.StampId ?? null;
532
695
  if (stampId !== null && revoked.has(stampId)) {
533
696
  continue;
@@ -554,7 +717,7 @@ export class ControlDatabase {
554
717
  async queryValidationKeys() {
555
718
  this.ensureInitialized();
556
719
  const keys = [];
557
- for await (const row of this.db.eval('select Key from CadreControl.ValidationKey')) {
720
+ for (const row of await this.readRows('select Key from CadreControl.ValidationKey', undefined, 'validation-keys')) {
558
721
  keys.push(row.Key);
559
722
  }
560
723
  return keys.sort();
@@ -573,11 +736,21 @@ export class ControlDatabase {
573
736
  * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
574
737
  * come from closed literal unions — no caller-supplied string can reach the statement
575
738
  * (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
739
+ *
740
+ * NOTE: `retry: false` is passed by exactly the call sites that run INSIDE a locked
741
+ * write body — {@link deleteGuardedRow} and {@link insertCadrePeer}'s insert-if-absent
742
+ * guard. A read retrying there would sleep its backoff HOLDING the write lock
743
+ * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
744
+ * the write funnel already re-runs the body's reads when it re-runs the body. The
745
+ * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
746
+ * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
747
+ * default retried path. Explicit per call rather than an ambient flag: unlocked reads
748
+ * run concurrently with a locked body, so `this`-state cannot tell the two apart.
576
749
  */
577
- async queryStampId(table, keyValue) {
750
+ async queryStampId(table, keyValue, retry = true) {
578
751
  this.ensureInitialized();
579
752
  const sql = `select StampId from CadreControl.${table} where ${GUARDED_KEY_COLUMN[table]} = ?`;
580
- for await (const row of this.db.eval(sql, [keyValue])) {
753
+ for (const row of await this.readRows(sql, [keyValue], `stamp-${table}`, retry)) {
581
754
  return row.StampId ?? null;
582
755
  }
583
756
  return null;
@@ -622,11 +795,14 @@ export class ControlDatabase {
622
795
  * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
623
796
  * gate request while the table only ever grows. Cheap today (a cadre removes peers
624
797
  * rarely); if removals ever become routine, cache the set and invalidate it on write.
798
+ *
799
+ * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
800
+ * under the write lock — see that method's note.
625
801
  */
626
- async queryRevokedStamps(tableName) {
802
+ async queryRevokedStamps(tableName, retry = true) {
627
803
  this.ensureInitialized();
628
804
  const stamps = new Set();
629
- for await (const row of this.db.eval('select StampId from CadreControl.Revocation where TableName = ?', [tableName])) {
805
+ for (const row of await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry)) {
630
806
  stamps.add(row.StampId);
631
807
  }
632
808
  return stamps;
@@ -642,7 +818,7 @@ export class ControlDatabase {
642
818
  async queryRevocations() {
643
819
  this.ensureInitialized();
644
820
  const rows = [];
645
- for await (const row of this.db.eval('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation')) {
821
+ for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
646
822
  rows.push({
647
823
  tableName: row.TableName,
648
824
  rowKey: row.RowKey,
@@ -663,7 +839,7 @@ export class ControlDatabase {
663
839
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
664
840
  * that seam (see {@link queryCadrePeers}).
665
841
  *
666
- * Missing/legacy column values are coalesced
842
+ * Missing/null column values are coalesced
667
843
  * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
668
844
  * verify/freshness gates uniformly reject an unpublished or malformed row.
669
845
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
@@ -673,7 +849,7 @@ export class ControlDatabase {
673
849
  async queryPeerRecord(peerId) {
674
850
  this.ensureInitialized();
675
851
  const revoked = await this.queryRevokedStamps('CadrePeer');
676
- for await (const row of this.db.eval('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
852
+ for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
677
853
  const stampId = row.StampId ?? null;
678
854
  if (stampId !== null && revoked.has(stampId)) {
679
855
  return null;
@@ -715,18 +891,18 @@ export class ControlDatabase {
715
891
  async updateSelfPeerRecord(record) {
716
892
  this.ensureInitialized();
717
893
  const multiaddr = record.addrs.join(',');
718
- await this.execWrite(`
719
- update CadreControl.CadrePeer
720
- with context OwnerKey = null, Signature = ?
721
- set Multiaddr = ?, UpdatedAt = ?, Sig = ?
722
- where PeerId = ?
894
+ await this.execWrite(`
895
+ update CadreControl.CadrePeer
896
+ with context OwnerKey = null, Signature = ?
897
+ set Multiaddr = ?, UpdatedAt = ?, Sig = ?
898
+ where PeerId = ?
723
899
  `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
724
900
  log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
725
901
  }
726
902
  /**
727
903
  * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
728
904
  *
729
- * Returns null when no row exists. Missing/legacy column values are coalesced to
905
+ * Returns null when no row exists. Missing/null column values are coalesced to
730
906
  * their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
731
907
  * gates uniformly reject an unpublished or malformed row. `platform` is returned
732
908
  * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
@@ -738,7 +914,7 @@ export class ControlDatabase {
738
914
  */
739
915
  async queryDeviceToken(peerId) {
740
916
  this.ensureInitialized();
741
- for await (const row of this.db.eval('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId])) {
917
+ for (const row of await this.readRows('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId], 'device-token')) {
742
918
  return {
743
919
  peerId: row.PeerId,
744
920
  platform: (row.Platform ?? ''),
@@ -763,11 +939,11 @@ export class ControlDatabase {
763
939
  */
764
940
  async updateSelfDeviceToken(record) {
765
941
  this.ensureInitialized();
766
- await this.execWrite(`
767
- update CadreControl.DeviceToken
768
- with context OwnerKey = null, Signature = ?
769
- set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
770
- where PeerId = ?
942
+ await this.execWrite(`
943
+ update CadreControl.DeviceToken
944
+ with context OwnerKey = null, Signature = ?
945
+ set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
946
+ where PeerId = ?
771
947
  `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
772
948
  log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
773
949
  }
@@ -786,16 +962,20 @@ export class ControlDatabase {
786
962
  // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
787
963
  // the StampId is a real column value, not the optimystic `StampId()` SQL function.
788
964
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
789
- await this.execWrite(`
790
- insert into CadreControl.OwnerKey (Key, StampId)
791
- with context OwnerKey = null, Signature = null
792
- values (?, ?)
965
+ await this.execWrite(`
966
+ insert into CadreControl.OwnerKey (Key, StampId)
967
+ with context OwnerKey = null, Signature = null
968
+ values (?, ?)
793
969
  `, [key, stampId], 'owner-key-insert');
794
970
  log('Owner key inserted');
795
971
  }
796
972
  /**
797
973
  * Insert a strand into the control database using an owner signature.
798
974
  *
975
+ * Fails with a `Strand.Id` uniqueness violation when the id is already seated — the
976
+ * caller decides whether that is a duplicate of its own earlier write (idempotent) or a
977
+ * genuine conflict; see {@link isStrandIdConflict} and `CadreNode.publishStrand`.
978
+ *
799
979
  * The owner signs the canonical row-bound authorization message (see
800
980
  * {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
801
981
  * this strand's contents and cannot be transplanted onto an attacker-chosen row. The
@@ -815,15 +995,18 @@ export class ControlDatabase {
815
995
  const peerId = this.config.libp2pNode.peerId.toString();
816
996
  const stampId = generateStampId(peerId);
817
997
  // Field order MUST match the schema's Strand `AuthorizedInsert` verify:
818
- // Id, Type, MemberPrivateKey ('' when null), StampId.
998
+ // Id, Type, MemberPrivateKey ('' when null), StampId. FounderOwnerKey is
999
+ // deliberately NOT in the digest — the schema binds it by equality to the
1000
+ // verified context.OwnerKey instead (see the constraint's comment).
819
1001
  const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
820
1002
  const signature = signMessage(message);
821
1003
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
822
- await this.execWrite(`
823
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
824
- with context OwnerKey = ?, Signature = ?
825
- values (?, ?, ?, ?)
826
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
1004
+ // FounderOwnerKey must equal the signing owner or the constraint rejects the row.
1005
+ await this.execWrite(`
1006
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
1007
+ with context OwnerKey = ?, Signature = ?
1008
+ values (?, ?, ?, ?, ?)
1009
+ `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey], 'strand-insert');
827
1010
  log('Strand inserted: %s', strandId);
828
1011
  }
829
1012
  /**
@@ -868,10 +1051,10 @@ export class ControlDatabase {
868
1051
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
869
1052
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
870
1053
  const signature = signMessage(message);
871
- await this.execWrite(`
872
- insert into CadreControl.ValidationKey (Key, StampId)
873
- with context OwnerKey = ?, Signature = ?
874
- values (?, ?)
1054
+ await this.execWrite(`
1055
+ insert into CadreControl.ValidationKey (Key, StampId)
1056
+ with context OwnerKey = ?, Signature = ?
1057
+ values (?, ?)
875
1058
  `, [ownerKey, signature, key, stampId], 'validation-key-insert');
876
1059
  log('Validation key inserted: %s', key);
877
1060
  }
@@ -938,7 +1121,8 @@ export class ControlDatabase {
938
1121
  const stampId = generateStampId(row.peerId);
939
1122
  const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
940
1123
  return await this.mutateCadrePeer('peer-insert', async () => {
941
- if (await this.queryStampId('CadrePeer', row.peerId) !== null) {
1124
+ // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
1125
+ if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
942
1126
  log('CadrePeer row already present for %s; insert skipped (already a member)', row.peerId);
943
1127
  return false;
944
1128
  }
@@ -948,10 +1132,10 @@ export class ControlDatabase {
948
1132
  // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
949
1133
  // identical to the context pair, which the AuthorizedInsert constraint binds — so a
950
1134
  // reader can later re-check the voucher against its node-local trusted-owner anchor.
951
- await this.db.exec(`
952
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
953
- with context OwnerKey = ?, Signature = ?
954
- values (?, ?, ?, ?, ?, ?, ?, ?)
1135
+ await this.db.exec(`
1136
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
1137
+ with context OwnerKey = ?, Signature = ?
1138
+ values (?, ?, ?, ?, ?, ?, ?, ?)
955
1139
  `, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
956
1140
  return true;
957
1141
  });
@@ -1014,11 +1198,11 @@ export class ControlDatabase {
1014
1198
  const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
1015
1199
  await this.mutateCadrePeer('peer-reauthorize', async () => {
1016
1200
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1017
- await this.db.exec(`
1018
- update CadreControl.CadrePeer
1019
- with context OwnerKey = ?, Signature = ?
1020
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1021
- where PeerId = ?
1201
+ await this.db.exec(`
1202
+ update CadreControl.CadrePeer
1203
+ with context OwnerKey = ?, Signature = ?
1204
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1205
+ where PeerId = ?
1022
1206
  `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
1023
1207
  });
1024
1208
  return true;
@@ -1097,7 +1281,8 @@ export class ControlDatabase {
1097
1281
  */
1098
1282
  async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
1099
1283
  this.ensureInitialized();
1100
- const stampId = await this.queryStampId(table, keyValue);
1284
+ // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1285
+ const stampId = await this.queryStampId(table, keyValue, false);
1101
1286
  if (stampId === null) {
1102
1287
  log('delete %s: no row for %s (already absent)', table, keyValue);
1103
1288
  return false;
@@ -1109,18 +1294,18 @@ export class ControlDatabase {
1109
1294
  // cover it — the digests are domain-separated and neither replays as the other.
1110
1295
  const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
1111
1296
  await this.inTransaction(`delete ${table}`, async () => {
1112
- await this.db.exec(`
1113
- delete from CadreControl.${table}
1114
- with context OwnerKey = ?, Signature = ?
1115
- where ${GUARDED_KEY_COLUMN[table]} = ?
1297
+ await this.db.exec(`
1298
+ delete from CadreControl.${table}
1299
+ with context OwnerKey = ?, Signature = ?
1300
+ where ${GUARDED_KEY_COLUMN[table]} = ?
1116
1301
  `, [ownerKey, signature, keyValue]);
1117
1302
  // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1118
1303
  // than leaning on the column default — the seat-at-zero rule is load-bearing for
1119
1304
  // reissueRevocations' monotonic bump, so state it at the write site.
1120
- await this.db.exec(`
1121
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1122
- with context OwnerKey = ?, Signature = ?
1123
- values (?, ?, ?, 0)
1305
+ await this.db.exec(`
1306
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1307
+ with context OwnerKey = ?, Signature = ?
1308
+ values (?, ?, ?, 0)
1124
1309
  `, [ownerKey, revocationSignature, table, keyValue, stampId]);
1125
1310
  });
1126
1311
  log('%s deleted: %s (stamp retired)', table, keyValue);
@@ -1197,10 +1382,10 @@ export class ControlDatabase {
1197
1382
  log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
1198
1383
  return false;
1199
1384
  }
1200
- const sql = `
1201
- delete from CadreControl.${table}
1202
- with context OwnerKey = null, Signature = null
1203
- where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1385
+ const sql = `
1386
+ delete from CadreControl.${table}
1387
+ with context OwnerKey = null, Signature = null
1388
+ where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1204
1389
  `;
1205
1390
  if (table === 'CadrePeer') {
1206
1391
  // Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
@@ -1358,11 +1543,11 @@ export class ControlDatabase {
1358
1543
  // output minted per row incarnation (generateStampId), so it identifies one
1359
1544
  // row across all TableName values. Do NOT "fix" this by adding TableName to
1360
1545
  // the where clause.
1361
- await this.db.exec(`
1362
- update CadreControl.Revocation
1363
- with context OwnerKey = ?, Signature = ?
1364
- set ReissuedAt = ?
1365
- where StampId = ?
1546
+ await this.db.exec(`
1547
+ update CadreControl.Revocation
1548
+ with context OwnerKey = ?, Signature = ?
1549
+ set ReissuedAt = ?
1550
+ where StampId = ?
1366
1551
  `, [ownerKey, signature, reissuedAt, row.stampId]);
1367
1552
  executed++;
1368
1553
  }
@@ -1484,7 +1669,8 @@ export class ControlDatabase {
1484
1669
  * {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
1485
1670
  * open transaction — a torn commit if either side rolls back. This lock closes that
1486
1671
  * class: every public write method of this class runs its statement(s) under it, and
1487
- * `SeedBootstrapService` wraps its direct `db.exec` writes in it too. The race is
1672
+ * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
1673
+ * `Database` directly, so it is covered too. The race is
1488
1674
  * real, not theoretical: the background self-record publish on a control connection
1489
1675
  * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
1490
1676
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
@@ -1632,7 +1818,8 @@ export class ControlDatabase {
1632
1818
  * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
1633
1819
  * when set, a responder redeeming this token records a `FormationUsage` against that
1634
1820
  * strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
1635
- * null `StrandId` (the default) leaves the legacy responder-provisions path in place.
1821
+ * null `StrandId` (the default) takes the unbound responder-provisions path: the
1822
+ * responder provisions a fresh open strand and atomically records its one consent row.
1636
1823
  * Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
1637
1824
  *
1638
1825
  * The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
@@ -1677,10 +1864,10 @@ export class ControlDatabase {
1677
1864
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
1678
1865
  // signed source-of-truth and the stored value are produced once. StampId is a real,
1679
1866
  // unique column (single-use anti-replay), no longer a context value.
1680
- await this.execWrite(`
1681
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
1682
- with context OwnerKey = ?, Signature = ?
1683
- values (?, ?, ?, ?, ?, ?, ?)
1867
+ await this.execWrite(`
1868
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
1869
+ with context OwnerKey = ?, Signature = ?
1870
+ values (?, ?, ?, ?, ?, ?, ?)
1684
1871
  `, [
1685
1872
  ownerKey, signature,
1686
1873
  token, sAppId,
@@ -1746,11 +1933,13 @@ export class ControlDatabase {
1746
1933
  await this.inTransaction('redemption', async () => {
1747
1934
  // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
1748
1935
  // still carrying a fresh unique StampId for the anti-replay column.
1749
- // Hard-coded open + keyless: the consent branch admits no other shape.
1750
- await this.db.exec(`
1751
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
1752
- with context OwnerKey = null, Signature = null
1753
- values (?, 'o', null, ?)
1936
+ // Hard-coded open + keyless + explicit null FounderOwnerKey: the
1937
+ // consent branch admits no other shape (no signature means no
1938
+ // trustworthy founder machine to record — see the schema's NOTE).
1939
+ await this.db.exec(`
1940
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey)
1941
+ with context OwnerKey = null, Signature = null
1942
+ values (?, 'o', null, ?, null)
1754
1943
  `, [strandId, strandStampId]);
1755
1944
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
1756
1945
  // carrying the strand's stamp so it authorizes THIS row and no other.
@@ -1844,13 +2033,17 @@ export class ControlDatabase {
1844
2033
  // is still open (tracked by `debt-composite-pk-point-lookup-unreliable-untracked`). A
1845
2034
  // spurious empty result only costs the attempt its NAMED exhaustion error, reverting it to
1846
2035
  // today's generic `Authorized` refusal — never a seat the invite does not have.
2036
+ // Both reads pass retry: false — assertSeatRemains only ever runs inside the locked
2037
+ // write bodies of redeemInvitation / recordFormationUsage, where a read retrying its
2038
+ // own backoff would sleep holding the write lock and the write funnel re-runs these
2039
+ // reads anyway (see the NOTE on readRows).
1847
2040
  const totalUses = knownTotalUses !== undefined
1848
2041
  ? knownTotalUses
1849
- : (await this.queryFormationInvite(token))?.totalUses ?? null;
2042
+ : (await this.queryFormationInvite(token, false))?.totalUses ?? null;
1850
2043
  if (totalUses == null) {
1851
2044
  return;
1852
2045
  }
1853
- const used = await this.countFormationUsage(token);
2046
+ const used = await this.countFormationUsage(token, false);
1854
2047
  if (used >= totalUses) {
1855
2048
  throw new InvitationExhaustedError(token, used, totalUses);
1856
2049
  }
@@ -1866,10 +2059,10 @@ export class ControlDatabase {
1866
2059
  // robustness/consistency change, matching the strand layer's `consumeInvite`,
1867
2060
  // not a fix for an observable mis-ordering.
1868
2061
  const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
1869
- await this.db.exec(`
1870
- insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
1871
- with context Now = ?, ValidationKey = ?, ValidationSignature = ?
1872
- values (?, ?, ?, ?, ?, ?, ?)
2062
+ await this.db.exec(`
2063
+ insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
2064
+ with context Now = ?, ValidationKey = ?, ValidationSignature = ?
2065
+ values (?, ?, ?, ?, ?, ?, ?)
1873
2066
  `, [
1874
2067
  nowCanonical, opts.validationKey, opts.validationSignature,
1875
2068
  opts.token, opts.usageStampId, opts.peerKey,
@@ -1880,10 +2073,15 @@ export class ControlDatabase {
1880
2073
  * Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
1881
2074
  * the parsed epoch-ms of the stored `datetime` (null when the invite never
1882
2075
  * expires); the caller compares it against the wall clock for freshness.
2076
+ *
2077
+ * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
2078
+ * locked write body — same per-call opt-out, and for the same reason, as
2079
+ * {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
1883
2080
  */
1884
- async queryFormationInvite(token) {
2081
+ async queryFormationInvite(token, retry = true) {
1885
2082
  this.ensureInitialized();
1886
- for await (const row of this.db.eval('select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?', [token])) {
2083
+ const sql = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?';
2084
+ for (const row of await this.readRows(sql, [token], 'formation-invite', retry)) {
1887
2085
  return {
1888
2086
  token: row.Token,
1889
2087
  sAppId: row.sAppId,
@@ -1898,25 +2096,31 @@ export class ControlDatabase {
1898
2096
  /**
1899
2097
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
1900
2098
  *
1901
- * NOTE: `Token` left the primary key in `formation-unique-token-redesign` and gained the
1902
- * `FormationUsageByToken` secondary index, so this filter is now served by an index SEEK
1903
- * (a descent through the index collection) where it used to be a leading-key partial match
1904
- * the optimystic vtab declined and the engine served by scan. That matters because the seat
1905
- * cap is this count: a descent that under-reports admits a seat the invite does not have,
1906
- * where a scan could not. Descent misses have been observed on a NETWORKED store (tracked
1907
- * by `debt-composite-pk-point-lookup-unreliable-untracked`, for full-primary-key lookups);
1908
- * no miss has been observed on this index on a SINGLE node. Across nodes the index is
1909
- * currently blind: a sibling never learns index entries for rows written elsewhere (the
1910
- * distributed commit drops the index update — measured 2026-08-12, tracked by
1911
- * `blocked/secondary-index-seek-blind-to-sibling-rows`), so on a multi-machine party this
1912
- * count under-reports and the cap over-admits until the upstream fix lands. If
1913
- * over-admission ever shows up on a converged single node — a token with more usage rows
1914
- * than its `TotalUses` and no concurrent redeemers to explain it — suspect this seek first
1915
- * and drop the index to fall back to the scan.
2099
+ * This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
2100
+ * the invitation never paid for. It is served by a table SCAN, deliberately: a bare
2101
+ * equality on a non-key column is a shape the optimystic vtab declines to claim, so the
2102
+ * engine applies the filter itself over a full read of the table.
2103
+ *
2104
+ * NOTE: it was briefly an index seek, and that is what the scan is protecting against.
2105
+ * `formation-unique-token-redesign` took `Token` out of the primary key and declared
2106
+ * `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
2107
+ * under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
2108
+ * not produce". The tripwire fired. Across machines a secondary-index descent returns only
2109
+ * the rows the READING machine wrote, so this count under-reported without bound on a
2110
+ * multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
2111
+ * measurement is recorded in the block comment at the `FormationUsage` declaration in
2112
+ * `schemas/control.qsql`, and the engine defect in
2113
+ * `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
2114
+ * speed this up while that ticket is open.
2115
+ *
2116
+ * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
2117
+ * locked write body — same per-call opt-out, and for the same reason, as
2118
+ * {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
1916
2119
  */
1917
- async countFormationUsage(token) {
2120
+ async countFormationUsage(token, retry = true) {
1918
2121
  this.ensureInitialized();
1919
- for await (const row of this.db.eval('select count(1) as Count from CadreControl.FormationUsage where Token = ?', [token])) {
2122
+ const sql = 'select count(1) as Count from CadreControl.FormationUsage where Token = ?';
2123
+ for (const row of await this.readRows(sql, [token], 'formation-usage-count', retry)) {
1920
2124
  return row.Count ?? 0;
1921
2125
  }
1922
2126
  return 0;
@@ -1947,7 +2151,7 @@ export class ControlDatabase {
1947
2151
  // prune redeemed/expired rows.
1948
2152
  const metered = [];
1949
2153
  let unlimitedOutstanding = false;
1950
- for await (const row of this.db.eval('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite')) {
2154
+ for (const row of await this.readRows('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite', undefined, 'outstanding-invites')) {
1951
2155
  const expiresAtMs = parseNullableStoredDatetimeMs(row.ExpiresAt);
1952
2156
  if (expiresAtMs !== null && expiresAtMs <= nowMs) {
1953
2157
  continue;
@@ -1963,6 +2167,12 @@ export class ControlDatabase {
1963
2167
  if (unlimitedOutstanding) {
1964
2168
  return true;
1965
2169
  }
2170
+ // NOTE: this loop issues one retried read per metered invite, each carrying its own
2171
+ // CONTROL_READ_RETRY_BUDGET_MS, on a path the inbound admission gate awaits under a
2172
+ // 2 s FAIL-OPEN deadline — so with N unexpired metered invites the worst case is
2173
+ // (1 + N) budgets, not one. Fine while a cadre holds a couple of live invites and
2174
+ // transient read failures are rare; if a party ever keeps many metered invites open,
2175
+ // give this whole method one shared deadline rather than raising the per-read budget.
1966
2176
  for (const invite of metered) {
1967
2177
  if (await this.countFormationUsage(invite.token) < invite.totalUses) {
1968
2178
  return true;