@serfab/cadre-core 0.11.0 → 0.12.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 (86) hide show
  1. package/dist/announce-addrs.d.ts +49 -0
  2. package/dist/announce-addrs.js +78 -0
  3. package/dist/announce-addrs.js.map +1 -0
  4. package/dist/cadre-node.d.ts +281 -30
  5. package/dist/cadre-node.js +513 -103
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/control-database.d.ts +155 -23
  8. package/dist/control-database.js +272 -104
  9. package/dist/control-database.js.map +1 -1
  10. package/dist/control-read-retry.d.ts +103 -0
  11. package/dist/control-read-retry.js +145 -0
  12. package/dist/control-read-retry.js.map +1 -0
  13. package/dist/control-retry.d.ts +101 -0
  14. package/dist/control-retry.js +108 -0
  15. package/dist/control-retry.js.map +1 -0
  16. package/dist/control-schema.d.ts +1 -1
  17. package/dist/control-schema.js +20 -4
  18. package/dist/control-schema.js.map +1 -1
  19. package/dist/control-write-retry.d.ts +59 -35
  20. package/dist/control-write-retry.js +21 -92
  21. package/dist/control-write-retry.js.map +1 -1
  22. package/dist/delegate-admission.d.ts +17 -15
  23. package/dist/delegate-admission.js +17 -15
  24. package/dist/delegate-admission.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +17 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/membership-connection-gater.d.ts +211 -25
  29. package/dist/membership-connection-gater.js +309 -29
  30. package/dist/membership-connection-gater.js.map +1 -1
  31. package/dist/peer-record.d.ts +26 -0
  32. package/dist/peer-record.js +32 -0
  33. package/dist/peer-record.js.map +1 -1
  34. package/dist/relay-addrs.d.ts +76 -26
  35. package/dist/relay-addrs.js +115 -27
  36. package/dist/relay-addrs.js.map +1 -1
  37. package/dist/relay-reservation.d.ts +20 -15
  38. package/dist/relay-reservation.js +18 -14
  39. package/dist/relay-reservation.js.map +1 -1
  40. package/dist/strand-addr-protocol.js +16 -0
  41. package/dist/strand-addr-protocol.js.map +1 -1
  42. package/dist/strand-backfill.js +13 -4
  43. package/dist/strand-backfill.js.map +1 -1
  44. package/dist/strand-formation-manager.d.ts +10 -9
  45. package/dist/strand-formation-manager.js +10 -9
  46. package/dist/strand-formation-manager.js.map +1 -1
  47. package/dist/strand-formation-protocol.d.ts +7 -7
  48. package/dist/strand-formation-protocol.js +8 -8
  49. package/dist/strand-formation-protocol.js.map +1 -1
  50. package/dist/strand-instance-manager.d.ts +4 -3
  51. package/dist/strand-instance-manager.js +31 -6
  52. package/dist/strand-instance-manager.js.map +1 -1
  53. package/dist/strand-membership-writer.d.ts +106 -14
  54. package/dist/strand-membership-writer.js +180 -14
  55. package/dist/strand-membership-writer.js.map +1 -1
  56. package/dist/strand-solicitation.d.ts +3 -3
  57. package/dist/strand-solicitation.js +4 -5
  58. package/dist/strand-solicitation.js.map +1 -1
  59. package/dist/strand-wake-protocol.d.ts +47 -2
  60. package/dist/strand-wake-protocol.js +76 -6
  61. package/dist/strand-wake-protocol.js.map +1 -1
  62. package/dist/types.d.ts +74 -29
  63. package/dist/types.js.map +1 -1
  64. package/package.json +8 -8
  65. package/src/announce-addrs.ts +93 -0
  66. package/src/cadre-node.ts +5603 -5158
  67. package/src/control-database.ts +2584 -2406
  68. package/src/control-read-retry.ts +167 -0
  69. package/src/control-retry.ts +190 -0
  70. package/src/control-schema.ts +20 -4
  71. package/src/control-write-retry.ts +30 -130
  72. package/src/delegate-admission.ts +17 -15
  73. package/src/index.ts +435 -407
  74. package/src/membership-connection-gater.ts +533 -204
  75. package/src/peer-record.ts +33 -0
  76. package/src/relay-addrs.ts +218 -111
  77. package/src/relay-reservation.ts +828 -823
  78. package/src/strand-addr-protocol.ts +17 -0
  79. package/src/strand-backfill.ts +389 -366
  80. package/src/strand-formation-manager.ts +492 -491
  81. package/src/strand-formation-protocol.ts +8 -8
  82. package/src/strand-instance-manager.ts +636 -611
  83. package/src/strand-membership-writer.ts +209 -14
  84. package/src/strand-solicitation.ts +6 -7
  85. package/src/strand-wake-protocol.ts +94 -8
  86. package/src/types.ts +74 -29
@@ -9,6 +9,7 @@ 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';
12
13
  const log = debug('sereus:cadre:control-db');
13
14
  const timing = debug('sereus:cadre:timing');
14
15
  /**
@@ -248,6 +249,13 @@ export class ControlDatabase {
248
249
  * having to restate the policy's attempts or classifier.
249
250
  */
250
251
  this.controlWriteRetryPacing = {};
252
+ /**
253
+ * Pacing seams for {@link readRows}'s retry — the read twin of
254
+ * {@link controlWriteRetryPacing}, with the same contract: production leaves this
255
+ * empty, specs inject a recorded `sleep` / fake `now` so no test waits out a real
256
+ * backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
257
+ */
258
+ this.controlReadRetryPacing = {};
251
259
  this.config = config;
252
260
  }
253
261
  /**
@@ -396,13 +404,126 @@ export class ControlDatabase {
396
404
  await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
397
405
  log('Schema loaded and executed');
398
406
  }
407
+ /**
408
+ * One UNRETRIED drain of a control read to rows — every read in this class bottoms out
409
+ * here (almost all via {@link readRows}, which adds the transient-failure retry), and
410
+ * this is the ONE place the committed-read opt-in is spelled.
411
+ *
412
+ * Reached only through {@link readRows}, which drops straight here for the call sites
413
+ * that pass `retry: false` — reads issued from INSIDE a locked write body, where a
414
+ * backoff sleep would hold the write lock and the write funnel re-runs the read anyway
415
+ * (see the NOTE on {@link readRows}).
416
+ *
417
+ * A control read normally queues on the database's exec mutex, so it does not answer
418
+ * until whatever statement holds that mutex finishes. When a control WRITE is parked
419
+ * against an unresponsive cohort member that is minutes, and a read that only wants
420
+ * the last committed state waits the whole time for no reason
421
+ * (`complete/control-reads-blocked-by-stalled-write`).
422
+ *
423
+ * Quereus's remedy is `readConcurrency: 'committed'` — a PER-CALL opt-in
424
+ * (`Statement.tryRouteConcurrent` refuses anything that did not ask for it by name)
425
+ * that runs an eligible read off the mutex against each table's last committed state.
426
+ * Routing is best-effort and never an error: an ineligible statement falls back to
427
+ * the serialized path silently — any side-effecting node, any table-valued function
428
+ * call (pure ones included), any table whose module does not declare
429
+ * `readCommittedSnapshot`, or an EXPLICIT transaction being open on this database.
430
+ *
431
+ * **It is asked for only while a write is in flight, and that condition is the whole
432
+ * point.** The opt-in does not merely move the read off the mutex: it connects the
433
+ * table with `_readCommitted`, and the optimystic vtab serves such a connection from
434
+ * a pinned pre-transaction snapshot that NEVER refreshes from the network, where an
435
+ * ordinary read calls `update()` on the collection first. Taking that path
436
+ * unconditionally therefore trades "reads block behind a stalled write" for "reads
437
+ * stop seeing what other machines wrote" — measured, not theorised: with every read
438
+ * opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
439
+ * publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
440
+ *
441
+ * `getAutocommit()` reports the whole `Database` rather than this call (the same
442
+ * property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
443
+ * false means some writer's transaction is open, which is the only situation where a
444
+ * read would have blocked. The check races that writer harmlessly in both directions
445
+ * — losing it either reads normally (today's behaviour) or reads committed state.
446
+ *
447
+ * What a committed read gives up is observing a write that has not finished
448
+ * committing, and that is safe for every caller here: an AWAITED write is committed by
449
+ * the time it returns, and a read inside {@link inTransaction} is disqualified by the
450
+ * open explicit transaction and stays serialized, so it still sees its own
451
+ * transaction's rows. A read after an UNAWAITED write on this database would be wrong
452
+ * — no such caller exists, and {@link withWriteLock} is what keeps it that way.
453
+ */
454
+ async readRowsOnce(sql, params) {
455
+ let iterator;
456
+ if (this.db.getAutocommit()) {
457
+ iterator = this.db.eval(sql, params);
458
+ }
459
+ else {
460
+ log('read-eval: a write is in flight, asking for a committed read: %s', sql);
461
+ iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
462
+ }
463
+ const rows = [];
464
+ for await (const row of iterator) {
465
+ rows.push(row);
466
+ }
467
+ return rows;
468
+ }
469
+ /**
470
+ * {@link readRowsOnce} plus the bounded transient-failure retry
471
+ * ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
472
+ * read that failed because the cluster could not be asked properly (a stream reset
473
+ * mid-scan, a partially unreachable cohort) is re-presented a moment later instead of
474
+ * surfacing to every reader.
475
+ *
476
+ * MATERIALIZES the read rather than returning a lazy iterator, and that shape is what
477
+ * makes the retry possible at all: the failure happens during ITERATION, not at call
478
+ * time, and a half-consumed iterator cannot be retried without re-yielding rows the
479
+ * caller already saw. Materializing is safe here — every caller either drains to an
480
+ * array anyway or takes the first row of a statement that yields at most one (a
481
+ * primary-key lookup or a `count(1)`); nothing streams.
482
+ *
483
+ * Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
484
+ * (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
485
+ * and change the right answer.
486
+ *
487
+ * The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
488
+ * under the write funnel's: the tightest caller deadline over a control read is the
489
+ * inbound admission gate's 2 s FAIL-OPEN timeout, and a read that outlives it spends its
490
+ * retries after the gate has already admitted (see `control-read-retry.ts`).
491
+ *
492
+ * `label` names the read in the retry loop's `Control read [<label>] …` debug lines and
493
+ * nothing else; several reads are in flight concurrently in a real party, so an
494
+ * unlabelled line cannot be attributed — keep new call sites labelled.
495
+ *
496
+ * `retry: false` drops straight to {@link readRowsOnce}: reads issued from INSIDE a
497
+ * locked write body must not retry on their own. `retryControlWrite`'s contract is that
498
+ * backoff sleeps happen with NO lock held; a read retrying its own backoff inside a
499
+ * locked body would sleep holding the write lock (stalling every other local writer),
500
+ * and the write funnel already re-runs the body's reads when it re-runs the body. See
501
+ * {@link queryStampId} / {@link assertSeatRemains} / {@link queryCadrePeers} for the
502
+ * per-call opt-outs.
503
+ *
504
+ * NOTE: nothing structural enforces that opt-out — it is per-call because an unlocked
505
+ * read runs CONCURRENTLY with a locked body, so `this`-state cannot tell the two apart,
506
+ * and there is no async-context primitive available on every target platform (browser,
507
+ * React Native) to carry the answer. Reads reached through a CALLBACK invoked under the
508
+ * lock are the easy miss: `notifyMembershipChanged` runs its listener with the lock
509
+ * held, and its membership read is opted out at {@link queryCadrePeers}. If another
510
+ * under-lock callback seam is ever added, audit its read graph the same way.
511
+ */
512
+ readRows(sql, params, label, retry = true) {
513
+ if (!retry) {
514
+ return this.readRowsOnce(sql, params);
515
+ }
516
+ // The label lands LAST so the call site's name survives the spec-injected pacing —
517
+ // it changes log attribution only, never behaviour (same layering as lockedWithRetry).
518
+ return retryControlRead(() => this.readRowsOnce(sql, params), { ...this.controlReadRetryPacing, label });
519
+ }
399
520
  /**
400
521
  * Query all strands from the control database
401
522
  */
402
523
  async queryStrands() {
403
524
  this.ensureInitialized();
404
525
  const results = [];
405
- for await (const row of this.db.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand')) {
526
+ for (const row of await this.readRows('select Id, MemberPrivateKey, Type from CadreControl.Strand', undefined, 'strands')) {
406
527
  results.push({
407
528
  Id: row.Id,
408
529
  MemberPrivateKey: row.MemberPrivateKey,
@@ -419,7 +540,7 @@ export class ControlDatabase {
419
540
  */
420
541
  async queryStrand(strandId) {
421
542
  this.ensureInitialized();
422
- for await (const row of this.db.eval('select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?', [strandId])) {
543
+ for (const row of await this.readRows('select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?', [strandId], 'strand')) {
423
544
  return {
424
545
  Id: row.Id,
425
546
  MemberPrivateKey: row.MemberPrivateKey,
@@ -444,7 +565,7 @@ export class ControlDatabase {
444
565
  if (!CONTROL_TABLE_SET.has(table)) {
445
566
  throw new Error(`Unknown CadreControl table: ${table}`);
446
567
  }
447
- for await (const row of this.db.eval(`select count(1) as Count from CadreControl.${table}`)) {
568
+ for (const row of await this.readRows(`select count(1) as Count from CadreControl.${table}`, undefined, 'count-rows')) {
448
569
  return row.Count ?? 0;
449
570
  }
450
571
  return 0;
@@ -462,7 +583,7 @@ export class ControlDatabase {
462
583
  */
463
584
  async hasOwnerKey() {
464
585
  this.ensureInitialized();
465
- for await (const row of this.db.eval('select count(1) as Count from CadreControl.OwnerKey')) {
586
+ for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
466
587
  return row.Count > 0;
467
588
  }
468
589
  return false;
@@ -493,7 +614,7 @@ export class ControlDatabase {
493
614
  async getOwnerKeys() {
494
615
  this.ensureInitialized();
495
616
  const keys = new Set();
496
- for await (const row of this.db.eval('select Key from CadreControl.OwnerKey')) {
617
+ for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
497
618
  keys.add(row.Key);
498
619
  }
499
620
  return keys;
@@ -522,12 +643,21 @@ export class ControlDatabase {
522
643
  * carried into both, or those suites keep passing against a contract the database no
523
644
  * longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
524
645
  * second dimension, give the fakes a shared fixture or a contract test instead.
646
+ *
647
+ * `retry: false` is passed by exactly one caller —
648
+ * `CadreNode.refreshAuthorizedControlPeers`, the membership-gate snapshot refresh —
649
+ * because {@link notifyMembershipChanged} invokes that listener with the write lock
650
+ * HELD, and a retrying read there would sleep its backoff holding the lock (see the
651
+ * NOTE on {@link readRows}). It costs nothing: that refresh is best-effort, keeps the
652
+ * previous snapshot on failure, and is re-driven by the next write and by the timed
653
+ * reconcile. BOTH of this method's reads take the flag — retrying either one under the
654
+ * lock is the same defect.
525
655
  */
526
- async queryCadrePeers() {
656
+ async queryCadrePeers(retry = true) {
527
657
  this.ensureInitialized();
528
- const revoked = await this.queryRevokedStamps('CadrePeer');
658
+ const revoked = await this.queryRevokedStamps('CadrePeer', retry);
529
659
  const rows = [];
530
- for await (const row of this.db.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
660
+ for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
531
661
  const stampId = row.StampId ?? null;
532
662
  if (stampId !== null && revoked.has(stampId)) {
533
663
  continue;
@@ -554,7 +684,7 @@ export class ControlDatabase {
554
684
  async queryValidationKeys() {
555
685
  this.ensureInitialized();
556
686
  const keys = [];
557
- for await (const row of this.db.eval('select Key from CadreControl.ValidationKey')) {
687
+ for (const row of await this.readRows('select Key from CadreControl.ValidationKey', undefined, 'validation-keys')) {
558
688
  keys.push(row.Key);
559
689
  }
560
690
  return keys.sort();
@@ -573,11 +703,21 @@ export class ControlDatabase {
573
703
  * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
574
704
  * come from closed literal unions — no caller-supplied string can reach the statement
575
705
  * (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
706
+ *
707
+ * NOTE: `retry: false` is passed by exactly the call sites that run INSIDE a locked
708
+ * write body — {@link deleteGuardedRow} and {@link insertCadrePeer}'s insert-if-absent
709
+ * guard. A read retrying there would sleep its backoff HOLDING the write lock
710
+ * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
711
+ * the write funnel already re-runs the body's reads when it re-runs the body. The
712
+ * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
713
+ * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
714
+ * default retried path. Explicit per call rather than an ambient flag: unlocked reads
715
+ * run concurrently with a locked body, so `this`-state cannot tell the two apart.
576
716
  */
577
- async queryStampId(table, keyValue) {
717
+ async queryStampId(table, keyValue, retry = true) {
578
718
  this.ensureInitialized();
579
719
  const sql = `select StampId from CadreControl.${table} where ${GUARDED_KEY_COLUMN[table]} = ?`;
580
- for await (const row of this.db.eval(sql, [keyValue])) {
720
+ for (const row of await this.readRows(sql, [keyValue], `stamp-${table}`, retry)) {
581
721
  return row.StampId ?? null;
582
722
  }
583
723
  return null;
@@ -622,11 +762,14 @@ export class ControlDatabase {
622
762
  * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
623
763
  * gate request while the table only ever grows. Cheap today (a cadre removes peers
624
764
  * rarely); if removals ever become routine, cache the set and invalidate it on write.
765
+ *
766
+ * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
767
+ * under the write lock — see that method's note.
625
768
  */
626
- async queryRevokedStamps(tableName) {
769
+ async queryRevokedStamps(tableName, retry = true) {
627
770
  this.ensureInitialized();
628
771
  const stamps = new Set();
629
- for await (const row of this.db.eval('select StampId from CadreControl.Revocation where TableName = ?', [tableName])) {
772
+ for (const row of await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry)) {
630
773
  stamps.add(row.StampId);
631
774
  }
632
775
  return stamps;
@@ -642,7 +785,7 @@ export class ControlDatabase {
642
785
  async queryRevocations() {
643
786
  this.ensureInitialized();
644
787
  const rows = [];
645
- for await (const row of this.db.eval('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation')) {
788
+ for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
646
789
  rows.push({
647
790
  tableName: row.TableName,
648
791
  rowKey: row.RowKey,
@@ -663,7 +806,7 @@ export class ControlDatabase {
663
806
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
664
807
  * that seam (see {@link queryCadrePeers}).
665
808
  *
666
- * Missing/legacy column values are coalesced
809
+ * Missing/null column values are coalesced
667
810
  * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
668
811
  * verify/freshness gates uniformly reject an unpublished or malformed row.
669
812
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
@@ -673,7 +816,7 @@ export class ControlDatabase {
673
816
  async queryPeerRecord(peerId) {
674
817
  this.ensureInitialized();
675
818
  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])) {
819
+ for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
677
820
  const stampId = row.StampId ?? null;
678
821
  if (stampId !== null && revoked.has(stampId)) {
679
822
  return null;
@@ -715,18 +858,18 @@ export class ControlDatabase {
715
858
  async updateSelfPeerRecord(record) {
716
859
  this.ensureInitialized();
717
860
  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 = ?
861
+ await this.execWrite(`
862
+ update CadreControl.CadrePeer
863
+ with context OwnerKey = null, Signature = ?
864
+ set Multiaddr = ?, UpdatedAt = ?, Sig = ?
865
+ where PeerId = ?
723
866
  `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
724
867
  log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
725
868
  }
726
869
  /**
727
870
  * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
728
871
  *
729
- * Returns null when no row exists. Missing/legacy column values are coalesced to
872
+ * Returns null when no row exists. Missing/null column values are coalesced to
730
873
  * their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
731
874
  * gates uniformly reject an unpublished or malformed row. `platform` is returned
732
875
  * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
@@ -738,7 +881,7 @@ export class ControlDatabase {
738
881
  */
739
882
  async queryDeviceToken(peerId) {
740
883
  this.ensureInitialized();
741
- for await (const row of this.db.eval('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId])) {
884
+ for (const row of await this.readRows('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId], 'device-token')) {
742
885
  return {
743
886
  peerId: row.PeerId,
744
887
  platform: (row.Platform ?? ''),
@@ -763,11 +906,11 @@ export class ControlDatabase {
763
906
  */
764
907
  async updateSelfDeviceToken(record) {
765
908
  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 = ?
909
+ await this.execWrite(`
910
+ update CadreControl.DeviceToken
911
+ with context OwnerKey = null, Signature = ?
912
+ set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
913
+ where PeerId = ?
771
914
  `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
772
915
  log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
773
916
  }
@@ -786,10 +929,10 @@ export class ControlDatabase {
786
929
  // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
787
930
  // the StampId is a real column value, not the optimystic `StampId()` SQL function.
788
931
  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 (?, ?)
932
+ await this.execWrite(`
933
+ insert into CadreControl.OwnerKey (Key, StampId)
934
+ with context OwnerKey = null, Signature = null
935
+ values (?, ?)
793
936
  `, [key, stampId], 'owner-key-insert');
794
937
  log('Owner key inserted');
795
938
  }
@@ -819,10 +962,10 @@ export class ControlDatabase {
819
962
  const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
820
963
  const signature = signMessage(message);
821
964
  // 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 (?, ?, ?, ?)
965
+ await this.execWrite(`
966
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
967
+ with context OwnerKey = ?, Signature = ?
968
+ values (?, ?, ?, ?)
826
969
  `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
827
970
  log('Strand inserted: %s', strandId);
828
971
  }
@@ -868,10 +1011,10 @@ export class ControlDatabase {
868
1011
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
869
1012
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
870
1013
  const signature = signMessage(message);
871
- await this.execWrite(`
872
- insert into CadreControl.ValidationKey (Key, StampId)
873
- with context OwnerKey = ?, Signature = ?
874
- values (?, ?)
1014
+ await this.execWrite(`
1015
+ insert into CadreControl.ValidationKey (Key, StampId)
1016
+ with context OwnerKey = ?, Signature = ?
1017
+ values (?, ?)
875
1018
  `, [ownerKey, signature, key, stampId], 'validation-key-insert');
876
1019
  log('Validation key inserted: %s', key);
877
1020
  }
@@ -938,7 +1081,8 @@ export class ControlDatabase {
938
1081
  const stampId = generateStampId(row.peerId);
939
1082
  const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
940
1083
  return await this.mutateCadrePeer('peer-insert', async () => {
941
- if (await this.queryStampId('CadrePeer', row.peerId) !== null) {
1084
+ // retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
1085
+ if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
942
1086
  log('CadrePeer row already present for %s; insert skipped (already a member)', row.peerId);
943
1087
  return false;
944
1088
  }
@@ -948,10 +1092,10 @@ export class ControlDatabase {
948
1092
  // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
949
1093
  // identical to the context pair, which the AuthorizedInsert constraint binds — so a
950
1094
  // 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 (?, ?, ?, ?, ?, ?, ?, ?)
1095
+ await this.db.exec(`
1096
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
1097
+ with context OwnerKey = ?, Signature = ?
1098
+ values (?, ?, ?, ?, ?, ?, ?, ?)
955
1099
  `, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
956
1100
  return true;
957
1101
  });
@@ -1014,11 +1158,11 @@ export class ControlDatabase {
1014
1158
  const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
1015
1159
  await this.mutateCadrePeer('peer-reauthorize', async () => {
1016
1160
  // 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 = ?
1161
+ await this.db.exec(`
1162
+ update CadreControl.CadrePeer
1163
+ with context OwnerKey = ?, Signature = ?
1164
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1165
+ where PeerId = ?
1022
1166
  `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
1023
1167
  });
1024
1168
  return true;
@@ -1097,7 +1241,8 @@ export class ControlDatabase {
1097
1241
  */
1098
1242
  async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
1099
1243
  this.ensureInitialized();
1100
- const stampId = await this.queryStampId(table, keyValue);
1244
+ // retry: false — this runs inside the locked write body (see queryStampId's NOTE).
1245
+ const stampId = await this.queryStampId(table, keyValue, false);
1101
1246
  if (stampId === null) {
1102
1247
  log('delete %s: no row for %s (already absent)', table, keyValue);
1103
1248
  return false;
@@ -1109,18 +1254,18 @@ export class ControlDatabase {
1109
1254
  // cover it — the digests are domain-separated and neither replays as the other.
1110
1255
  const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
1111
1256
  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]} = ?
1257
+ await this.db.exec(`
1258
+ delete from CadreControl.${table}
1259
+ with context OwnerKey = ?, Signature = ?
1260
+ where ${GUARDED_KEY_COLUMN[table]} = ?
1116
1261
  `, [ownerKey, signature, keyValue]);
1117
1262
  // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1118
1263
  // than leaning on the column default — the seat-at-zero rule is load-bearing for
1119
1264
  // 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)
1265
+ await this.db.exec(`
1266
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1267
+ with context OwnerKey = ?, Signature = ?
1268
+ values (?, ?, ?, 0)
1124
1269
  `, [ownerKey, revocationSignature, table, keyValue, stampId]);
1125
1270
  });
1126
1271
  log('%s deleted: %s (stamp retired)', table, keyValue);
@@ -1197,10 +1342,10 @@ export class ControlDatabase {
1197
1342
  log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
1198
1343
  return false;
1199
1344
  }
1200
- const sql = `
1201
- delete from CadreControl.${table}
1202
- with context OwnerKey = null, Signature = null
1203
- where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1345
+ const sql = `
1346
+ delete from CadreControl.${table}
1347
+ with context OwnerKey = null, Signature = null
1348
+ where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1204
1349
  `;
1205
1350
  if (table === 'CadrePeer') {
1206
1351
  // Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
@@ -1358,11 +1503,11 @@ export class ControlDatabase {
1358
1503
  // output minted per row incarnation (generateStampId), so it identifies one
1359
1504
  // row across all TableName values. Do NOT "fix" this by adding TableName to
1360
1505
  // the where clause.
1361
- await this.db.exec(`
1362
- update CadreControl.Revocation
1363
- with context OwnerKey = ?, Signature = ?
1364
- set ReissuedAt = ?
1365
- where StampId = ?
1506
+ await this.db.exec(`
1507
+ update CadreControl.Revocation
1508
+ with context OwnerKey = ?, Signature = ?
1509
+ set ReissuedAt = ?
1510
+ where StampId = ?
1366
1511
  `, [ownerKey, signature, reissuedAt, row.stampId]);
1367
1512
  executed++;
1368
1513
  }
@@ -1484,7 +1629,8 @@ export class ControlDatabase {
1484
1629
  * {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
1485
1630
  * open transaction — a torn commit if either side rolls back. This lock closes that
1486
1631
  * 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
1632
+ * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
1633
+ * `Database` directly, so it is covered too. The race is
1488
1634
  * real, not theoretical: the background self-record publish on a control connection
1489
1635
  * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
1490
1636
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
@@ -1632,7 +1778,8 @@ export class ControlDatabase {
1632
1778
  * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
1633
1779
  * when set, a responder redeeming this token records a `FormationUsage` against that
1634
1780
  * strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
1635
- * null `StrandId` (the default) leaves the legacy responder-provisions path in place.
1781
+ * null `StrandId` (the default) takes the unbound responder-provisions path: the
1782
+ * responder provisions a fresh open strand and atomically records its one consent row.
1636
1783
  * Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
1637
1784
  *
1638
1785
  * The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
@@ -1677,10 +1824,10 @@ export class ControlDatabase {
1677
1824
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
1678
1825
  // signed source-of-truth and the stored value are produced once. StampId is a real,
1679
1826
  // 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 (?, ?, ?, ?, ?, ?, ?)
1827
+ await this.execWrite(`
1828
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
1829
+ with context OwnerKey = ?, Signature = ?
1830
+ values (?, ?, ?, ?, ?, ?, ?)
1684
1831
  `, [
1685
1832
  ownerKey, signature,
1686
1833
  token, sAppId,
@@ -1747,10 +1894,10 @@ export class ControlDatabase {
1747
1894
  // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
1748
1895
  // still carrying a fresh unique StampId for the anti-replay column.
1749
1896
  // 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, ?)
1897
+ await this.db.exec(`
1898
+ insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
1899
+ with context OwnerKey = null, Signature = null
1900
+ values (?, 'o', null, ?)
1754
1901
  `, [strandId, strandStampId]);
1755
1902
  // 2. FormationUsage row — authorised by the matching FormationInvite, and
1756
1903
  // carrying the strand's stamp so it authorizes THIS row and no other.
@@ -1844,13 +1991,17 @@ export class ControlDatabase {
1844
1991
  // is still open (tracked by `debt-composite-pk-point-lookup-unreliable-untracked`). A
1845
1992
  // spurious empty result only costs the attempt its NAMED exhaustion error, reverting it to
1846
1993
  // today's generic `Authorized` refusal — never a seat the invite does not have.
1994
+ // Both reads pass retry: false — assertSeatRemains only ever runs inside the locked
1995
+ // write bodies of redeemInvitation / recordFormationUsage, where a read retrying its
1996
+ // own backoff would sleep holding the write lock and the write funnel re-runs these
1997
+ // reads anyway (see the NOTE on readRows).
1847
1998
  const totalUses = knownTotalUses !== undefined
1848
1999
  ? knownTotalUses
1849
- : (await this.queryFormationInvite(token))?.totalUses ?? null;
2000
+ : (await this.queryFormationInvite(token, false))?.totalUses ?? null;
1850
2001
  if (totalUses == null) {
1851
2002
  return;
1852
2003
  }
1853
- const used = await this.countFormationUsage(token);
2004
+ const used = await this.countFormationUsage(token, false);
1854
2005
  if (used >= totalUses) {
1855
2006
  throw new InvitationExhaustedError(token, used, totalUses);
1856
2007
  }
@@ -1866,10 +2017,10 @@ export class ControlDatabase {
1866
2017
  // robustness/consistency change, matching the strand layer's `consumeInvite`,
1867
2018
  // not a fix for an observable mis-ordering.
1868
2019
  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 (?, ?, ?, ?, ?, ?, ?)
2020
+ await this.db.exec(`
2021
+ insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
2022
+ with context Now = ?, ValidationKey = ?, ValidationSignature = ?
2023
+ values (?, ?, ?, ?, ?, ?, ?)
1873
2024
  `, [
1874
2025
  nowCanonical, opts.validationKey, opts.validationSignature,
1875
2026
  opts.token, opts.usageStampId, opts.peerKey,
@@ -1880,10 +2031,15 @@ export class ControlDatabase {
1880
2031
  * Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
1881
2032
  * the parsed epoch-ms of the stored `datetime` (null when the invite never
1882
2033
  * expires); the caller compares it against the wall clock for freshness.
2034
+ *
2035
+ * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
2036
+ * locked write body — same per-call opt-out, and for the same reason, as
2037
+ * {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
1883
2038
  */
1884
- async queryFormationInvite(token) {
2039
+ async queryFormationInvite(token, retry = true) {
1885
2040
  this.ensureInitialized();
1886
- for await (const row of this.db.eval('select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?', [token])) {
2041
+ const sql = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?';
2042
+ for (const row of await this.readRows(sql, [token], 'formation-invite', retry)) {
1887
2043
  return {
1888
2044
  token: row.Token,
1889
2045
  sAppId: row.sAppId,
@@ -1898,25 +2054,31 @@ export class ControlDatabase {
1898
2054
  /**
1899
2055
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
1900
2056
  *
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.
2057
+ * This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
2058
+ * the invitation never paid for. It is served by a table SCAN, deliberately: a bare
2059
+ * equality on a non-key column is a shape the optimystic vtab declines to claim, so the
2060
+ * engine applies the filter itself over a full read of the table.
2061
+ *
2062
+ * NOTE: it was briefly an index seek, and that is what the scan is protecting against.
2063
+ * `formation-unique-token-redesign` took `Token` out of the primary key and declared
2064
+ * `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
2065
+ * under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
2066
+ * not produce". The tripwire fired. Across machines a secondary-index descent returns only
2067
+ * the rows the READING machine wrote, so this count under-reported without bound on a
2068
+ * multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
2069
+ * measurement is recorded in the block comment at the `FormationUsage` declaration in
2070
+ * `schemas/control.qsql`, and the engine defect in
2071
+ * `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
2072
+ * speed this up while that ticket is open.
2073
+ *
2074
+ * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
2075
+ * locked write body — same per-call opt-out, and for the same reason, as
2076
+ * {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
1916
2077
  */
1917
- async countFormationUsage(token) {
2078
+ async countFormationUsage(token, retry = true) {
1918
2079
  this.ensureInitialized();
1919
- for await (const row of this.db.eval('select count(1) as Count from CadreControl.FormationUsage where Token = ?', [token])) {
2080
+ const sql = 'select count(1) as Count from CadreControl.FormationUsage where Token = ?';
2081
+ for (const row of await this.readRows(sql, [token], 'formation-usage-count', retry)) {
1920
2082
  return row.Count ?? 0;
1921
2083
  }
1922
2084
  return 0;
@@ -1947,7 +2109,7 @@ export class ControlDatabase {
1947
2109
  // prune redeemed/expired rows.
1948
2110
  const metered = [];
1949
2111
  let unlimitedOutstanding = false;
1950
- for await (const row of this.db.eval('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite')) {
2112
+ for (const row of await this.readRows('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite', undefined, 'outstanding-invites')) {
1951
2113
  const expiresAtMs = parseNullableStoredDatetimeMs(row.ExpiresAt);
1952
2114
  if (expiresAtMs !== null && expiresAtMs <= nowMs) {
1953
2115
  continue;
@@ -1963,6 +2125,12 @@ export class ControlDatabase {
1963
2125
  if (unlimitedOutstanding) {
1964
2126
  return true;
1965
2127
  }
2128
+ // NOTE: this loop issues one retried read per metered invite, each carrying its own
2129
+ // CONTROL_READ_RETRY_BUDGET_MS, on a path the inbound admission gate awaits under a
2130
+ // 2 s FAIL-OPEN deadline — so with N unexpired metered invites the worst case is
2131
+ // (1 + N) budgets, not one. Fine while a cadre holds a couple of live invites and
2132
+ // transient read failures are rare; if a party ever keeps many metered invites open,
2133
+ // give this whole method one shared deadline rather than raising the per-read budget.
1966
2134
  for (const invite of metered) {
1967
2135
  if (await this.countFormationUsage(invite.token) < invite.totalUses) {
1968
2136
  return true;