@serfab/cadre-core 0.10.0 → 0.11.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 (105) hide show
  1. package/README.md +255 -255
  2. package/dist/cadre-node.d.ts +203 -28
  3. package/dist/cadre-node.js +445 -84
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-cohort.d.ts +26 -0
  6. package/dist/control-cohort.js +26 -0
  7. package/dist/control-cohort.js.map +1 -1
  8. package/dist/control-database.d.ts +82 -117
  9. package/dist/control-database.js +217 -295
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-formation-recorder.d.ts +8 -7
  12. package/dist/control-formation-recorder.js +8 -7
  13. package/dist/control-formation-recorder.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +789 -774
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/control-write-retry.d.ts +15 -5
  18. package/dist/control-write-retry.js +19 -18
  19. package/dist/control-write-retry.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/peer-addr-book.d.ts +92 -0
  24. package/dist/peer-addr-book.js +220 -0
  25. package/dist/peer-addr-book.js.map +1 -0
  26. package/dist/seed-bootstrap.d.ts +7 -0
  27. package/dist/seed-bootstrap.js +15 -13
  28. package/dist/seed-bootstrap.js.map +1 -1
  29. package/dist/strand-cohort.d.ts +4 -37
  30. package/dist/strand-cohort.js +4 -15
  31. package/dist/strand-cohort.js.map +1 -1
  32. package/dist/strand-database.d.ts +11 -16
  33. package/dist/strand-database.js +19 -7
  34. package/dist/strand-database.js.map +1 -1
  35. package/dist/strand-formation-manager.d.ts +8 -8
  36. package/dist/strand-formation-manager.js +15 -18
  37. package/dist/strand-formation-manager.js.map +1 -1
  38. package/dist/strand-instance-manager.d.ts +52 -21
  39. package/dist/strand-instance-manager.js +100 -34
  40. package/dist/strand-instance-manager.js.map +1 -1
  41. package/dist/strand-membership-writer.d.ts +6 -4
  42. package/dist/strand-membership-writer.js +38 -36
  43. package/dist/strand-membership-writer.js.map +1 -1
  44. package/dist/types.d.ts +39 -31
  45. package/dist/types.js.map +1 -1
  46. package/package.json +8 -7
  47. package/src/arachnode-stub.ts +159 -159
  48. package/src/bootstrap-peer-store-file.ts +44 -44
  49. package/src/bootstrap-peer-store.ts +172 -172
  50. package/src/cadre-node.ts +5158 -4771
  51. package/src/canonical-json.ts +30 -30
  52. package/src/control-authorization.ts +114 -114
  53. package/src/control-cohort.ts +130 -103
  54. package/src/control-database.ts +2406 -2506
  55. package/src/control-formation-recorder.ts +305 -304
  56. package/src/control-schema.ts +801 -786
  57. package/src/control-stream.ts +205 -205
  58. package/src/control-write-retry.ts +483 -473
  59. package/src/delegate-admission.ts +302 -302
  60. package/src/device-token.ts +94 -94
  61. package/src/diagnostics/connection-path.ts +277 -277
  62. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  63. package/src/ed25519-key.ts +134 -134
  64. package/src/enrollment.ts +199 -199
  65. package/src/file-durable-slot.ts +61 -61
  66. package/src/formation-approval.ts +604 -604
  67. package/src/fs-atomic.ts +107 -107
  68. package/src/hibernation-manager.ts +437 -437
  69. package/src/identity-key.ts +109 -109
  70. package/src/index.ts +1 -0
  71. package/src/key-store-file.ts +132 -132
  72. package/src/key-store.ts +99 -99
  73. package/src/membership-connection-gater.ts +204 -204
  74. package/src/node-local-snapshot.ts +260 -260
  75. package/src/peer-addr-book.ts +250 -0
  76. package/src/peer-authorization.ts +259 -259
  77. package/src/peer-record.ts +164 -164
  78. package/src/push-credentials.ts +84 -84
  79. package/src/push-node.ts +64 -64
  80. package/src/push-notifier-apns.ts +247 -247
  81. package/src/push-notifier-fcm.ts +220 -220
  82. package/src/push-notifier-shared.ts +28 -28
  83. package/src/push-notifier.ts +52 -52
  84. package/src/relay-addrs.ts +111 -111
  85. package/src/relay-reservation.ts +823 -823
  86. package/src/seed-bootstrap.ts +15 -15
  87. package/src/seed-trust-policy.ts +140 -140
  88. package/src/strand-addr-protocol.ts +441 -441
  89. package/src/strand-backfill.ts +366 -366
  90. package/src/strand-cohort.ts +38 -77
  91. package/src/strand-database.ts +202 -204
  92. package/src/strand-formation-manager.ts +491 -494
  93. package/src/strand-formation-protocol.ts +692 -692
  94. package/src/strand-instance-manager.ts +108 -50
  95. package/src/strand-member-key.ts +39 -39
  96. package/src/strand-member-registry.ts +182 -182
  97. package/src/strand-membership-writer.ts +1432 -1430
  98. package/src/strand-solicitation.ts +472 -472
  99. package/src/strand-transport-key.ts +58 -58
  100. package/src/strand-wake-payload.ts +32 -32
  101. package/src/strand-wake-protocol.ts +314 -314
  102. package/src/strand-watcher.ts +314 -314
  103. package/src/trusted-owner-store-file.ts +41 -41
  104. package/src/trusted-owner-store.ts +171 -171
  105. package/src/types.ts +1309 -1303
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { Database, registerPlugin, unwrapError } from '@quereus/quereus';
3
+ import { Database, registerPlugin } from '@quereus/quereus';
4
4
  import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
5
5
  import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
6
6
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
@@ -73,94 +73,30 @@ export class FormationAbortedError extends Error {
73
73
  }
74
74
  }
75
75
  /**
76
- * A redemption could not be given a `UseNumber` within the invite's seat budget: the use
77
- * number the writer would have to take is past `FormationInvite.TotalUses`, so the
78
- * invitation is spent.
76
+ * The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
77
+ * for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
78
+ * seat.
79
79
  *
80
- * Raised inside {@link ControlDatabase.redeemInvitation} / {@link ControlDatabase.recordFormationUsage}
81
- * on EVERY attempt, including the first: two redemptions of one token racing on the SAME node
82
- * serialize behind the local write queue, so the loser's first attempt already takes a use
83
- * number past the seat budget — `StrandFormationManager.validateToken`'s `isTokenUsed` check
84
- * cannot catch this, since it ran before either redemption wrote anything. Without this the
85
- * write would fail as a generic `CHECK constraint failed: Authorized`, which the manager
86
- * reports as a retryable `Formation conflict, retry` — telling the joiner to retry something
87
- * that can never succeed.
80
+ * Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
81
+ * the write, inside {@link ControlDatabase.redeemInvitation} /
82
+ * {@link ControlDatabase.recordFormationUsage} — and off nothing else. It exists because
83
+ * without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
84
+ * schema's own count-based cap clause, which the manager reports as a retryable
85
+ * `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
86
+ * Two redemptions of one token racing on the SAME node serialize behind the local write
87
+ * queue, so the loser reads the winner's committed row and is refused here by name —
88
+ * `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
89
+ * ran before either redemption wrote anything.
88
90
  */
89
91
  export class InvitationExhaustedError extends Error {
90
- constructor(token, useNumber, totalUses) {
91
- super(`Invitation ${token} is exhausted: use #${useNumber} is past its limit of ${totalUses} use(s)`);
92
+ constructor(token, usesRecorded, totalUses) {
93
+ super(`Invitation ${token} is exhausted: ${usesRecorded} of ${totalUses} use(s) already recorded`);
92
94
  this.token = token;
93
- this.useNumber = useNumber;
95
+ this.usesRecorded = usesRecorded;
94
96
  this.totalUses = totalUses;
95
97
  this.name = 'InvitationExhaustedError';
96
98
  }
97
99
  }
98
- /**
99
- * Attempts allowed per redemption: the first, plus two retries of a use number another
100
- * writer took. Bounded so sustained contention terminates in a clean rejection rather than
101
- * spinning; two retries is enough for the realistic case (a handful of nodes redeeming one
102
- * multi-use token at once) without holding the write lock hostage.
103
- *
104
- * NOTE: the attempts fire back-to-back, with no backoff and no jitter — each one only takes
105
- * and releases the write lock. Fine while cross-node contention on a single token is a
106
- * handful of writers; if one hot multi-use token ever draws sustained concurrent redemption
107
- * (a broadcast invite, a bulk onboarding run), all three attempts can burn against the same
108
- * competitor and the caller gets the engine's error. Add a short randomised delay between
109
- * attempts then — NOT a longer attempt budget, which would hold the write queue.
110
- */
111
- const USE_NUMBER_ATTEMPTS = 3;
112
- /**
113
- * The two error messages that mean "another writer took the `UseNumber` we picked", and
114
- * nothing else.
115
- *
116
- * - the `(Token, UseNumber)` primary-key collision, which the optimystic vtab reports on the
117
- * insert itself (`uniqueConstraintMessage` renders the columns table-qualified, no schema
118
- * prefix);
119
- * - `CHECK constraint failed: Monotonic` — `FormationUsage.Monotonic` contains a subquery, so
120
- * Quereus defers it to commit where it reads `committed.FormationUsage`. A concurrent row
121
- * that our insert's key probe did not see still trips it there. Same meaning, different
122
- * surface.
123
- *
124
- * Deliberately NOT matched: `FormationUsage.UsageStampId`'s `unique` (a repeated nonce is
125
- * replay, never a race — retrying it would re-present a spent approval), and every named
126
- * CHECK other than `Monotonic`. `Authorized` in particular renders with the same
127
- * `CHECK constraint failed:` prefix, so the constraint NAME is anchored with `\b` exactly as
128
- * `expectConstraintFailure` does rather than matched by prefix.
129
- *
130
- * NOTE: a CHECK failure names only the CONSTRAINT, never its table, and `Monotonic` is a
131
- * generic enough name that a second table could plausibly claim it. Today it is unique across
132
- * `schemas/control.qsql`, so the pattern cannot mis-fire; if another table ever declares a
133
- * `Monotonic` constraint, a failure of THAT constraint would be read here as a lost use-number
134
- * race and pointlessly retried up to {@link USE_NUMBER_ATTEMPTS} times. Rename the other
135
- * constraint (cheapest) rather than trying to recover the table from the message.
136
- */
137
- const LOST_USE_NUMBER_PATTERNS = [
138
- /UNIQUE constraint failed: FormationUsage\.Token, FormationUsage\.UseNumber/i,
139
- /CHECK constraint failed: Monotonic\b/,
140
- ];
141
- /**
142
- * Did this write fail purely because another writer took the `UseNumber` it picked — i.e. is
143
- * re-presenting the SAME approval under the next free use number the right response?
144
- *
145
- * Classifies by MESSAGE, walking the `cause` chain with Quereus' own {@link unwrapError}:
146
- * neither surface can be recognised by type. The deferred `Monotonic` failure is thrown as a
147
- * bare `QuereusError`, not a `ConstraintError` (see
148
- * `quereus/src/runtime/deferred-constraint-queue.ts`), so an `instanceof ConstraintError` gate
149
- * would silently drop the branch this retry exists for. Messages also carry an
150
- * ` (at line N, column M)` suffix and arrive wrapped when the write ran inside a transaction,
151
- * so the match is a substring test rather than an equality one.
152
- *
153
- * NOTE: this is the one piece of the retry that depends on engine error TEXT. Its own test
154
- * (`control-formation-use-number-retry.spec.ts`) produces all three messages from the real
155
- * engine rather than from string literals, so a reworded storage-layer error fails that spec
156
- * instead of silently disabling the retry.
157
- */
158
- export function isLostUseNumberRace(error) {
159
- if (!(error instanceof Error)) {
160
- return false;
161
- }
162
- return unwrapError(error).some(({ message }) => LOST_USE_NUMBER_PATTERNS.some(pattern => pattern.test(message)));
163
- }
164
100
  /**
165
101
  * Parse a stored Quereus `datetime` value into epoch milliseconds.
166
102
  *
@@ -377,6 +313,20 @@ export class ControlDatabase {
377
313
  timing('[controlDb] hydrate: %dms (tables=%d, indexes=%d)', Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
378
314
  log('Hydrated control catalog (tables=%d, indexes=%d)', hydrated.tables, hydrated.indexes);
379
315
  // Load and execute the schema
316
+ // NOTE: this is where a slow launch is felt. Duration is (raw-storage operations
317
+ // issued) × per-operation storage latency: ~1ms/op on an idle machine, but
318
+ // 50-90ms/op on a loaded disk or a phone's flash under launch contention. A cold
319
+ // start now reaches the backend 172 times (8 tables + 1 index, 21 distinct blocks
320
+ // — dominated by its 130 genuine writes), a warm restart 52, because cadre-core
321
+ // wraps every embedder storage in `@optimystic/db-p2p`'s write-through cache
322
+ // (@serfab/quereus-plugin-sereus's cached-storage.ts). Uncached the same start issued ~2000 operations — the
323
+ // upstream re-read amplification measured in
324
+ // tickets/complete/optimystic-block-read-amplification-on-control-start.md (and
325
+ // its follow-up ...-schema-catalog-reread-per-write-blows-storage-budgets.md),
326
+ // which also rules out the retry policy and cluster deadlines. The counts are
327
+ // pinned by packages/cadre-core/test/control-start-storage-op-budget.spec.ts; if
328
+ // they grow — e.g. the cache leaves this path — that spec fails before anyone
329
+ // has to debug a launch.
380
330
  t0 = performance.now();
381
331
  await this.loadSchema();
382
332
  timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
@@ -443,7 +393,7 @@ export class ControlDatabase {
443
393
  // also what lets schema init absorb optimystic's self-coordination grace refusal, which
444
394
  // is unsafe for control writes in general. Why, on
445
395
  // `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
446
- await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY);
396
+ await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
447
397
  log('Schema loaded and executed');
448
398
  }
449
399
  /**
@@ -564,6 +514,14 @@ export class ControlDatabase {
564
514
  * membership-gate refresh path; control tables are a handful of rows today, so this
565
515
  * is cheap — if they ever grow, fold the exclusion into one statement or cache the
566
516
  * retired-stamp set.
517
+ *
518
+ * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
519
+ * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
520
+ * which feed the node rows directly. Nothing enforces that the copies stay true, so a
521
+ * change to the filter's SHAPE here (what it keys on, which table it consults) must be
522
+ * carried into both, or those suites keep passing against a contract the database no
523
+ * longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
524
+ * second dimension, give the fakes a shared fixture or a contract test instead.
567
525
  */
568
526
  async queryCadrePeers() {
569
527
  this.ensureInitialized();
@@ -757,12 +715,12 @@ export class ControlDatabase {
757
715
  async updateSelfPeerRecord(record) {
758
716
  this.ensureInitialized();
759
717
  const multiaddr = record.addrs.join(',');
760
- await this.execWrite(`
761
- update CadreControl.CadrePeer
762
- with context OwnerKey = null, Signature = ?
763
- set Multiaddr = ?, UpdatedAt = ?, Sig = ?
764
- where PeerId = ?
765
- `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId]);
718
+ await this.execWrite(`
719
+ update CadreControl.CadrePeer
720
+ with context OwnerKey = null, Signature = ?
721
+ set Multiaddr = ?, UpdatedAt = ?, Sig = ?
722
+ where PeerId = ?
723
+ `, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
766
724
  log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
767
725
  }
768
726
  /**
@@ -805,12 +763,12 @@ export class ControlDatabase {
805
763
  */
806
764
  async updateSelfDeviceToken(record) {
807
765
  this.ensureInitialized();
808
- await this.execWrite(`
809
- update CadreControl.DeviceToken
810
- with context OwnerKey = null, Signature = ?
811
- set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
812
- where PeerId = ?
813
- `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId]);
766
+ await this.execWrite(`
767
+ update CadreControl.DeviceToken
768
+ with context OwnerKey = null, Signature = ?
769
+ set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
770
+ where PeerId = ?
771
+ `, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
814
772
  log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
815
773
  }
816
774
  /**
@@ -828,11 +786,11 @@ export class ControlDatabase {
828
786
  // StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
829
787
  // the StampId is a real column value, not the optimystic `StampId()` SQL function.
830
788
  const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
831
- await this.execWrite(`
832
- insert into CadreControl.OwnerKey (Key, StampId)
833
- with context OwnerKey = null, Signature = null
834
- values (?, ?)
835
- `, [key, stampId]);
789
+ await this.execWrite(`
790
+ insert into CadreControl.OwnerKey (Key, StampId)
791
+ with context OwnerKey = null, Signature = null
792
+ values (?, ?)
793
+ `, [key, stampId], 'owner-key-insert');
836
794
  log('Owner key inserted');
837
795
  }
838
796
  /**
@@ -861,11 +819,11 @@ export class ControlDatabase {
861
819
  const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
862
820
  const signature = signMessage(message);
863
821
  // StampId is a real, unique column (single-use anti-replay), no longer a context value.
864
- await this.execWrite(`
865
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
866
- with context OwnerKey = ?, Signature = ?
867
- values (?, ?, ?, ?)
868
- `, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
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');
869
827
  log('Strand inserted: %s', strandId);
870
828
  }
871
829
  /**
@@ -887,7 +845,7 @@ export class ControlDatabase {
887
845
  * when a row was actually removed.
888
846
  */
889
847
  deleteStrand(strandId, ownerKey, signMessage) {
890
- return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage));
848
+ return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage), {}, 'strand-delete');
891
849
  }
892
850
  /**
893
851
  * Insert a validation key into the control database using an owner signature.
@@ -910,11 +868,11 @@ export class ControlDatabase {
910
868
  // Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
911
869
  const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
912
870
  const signature = signMessage(message);
913
- await this.execWrite(`
914
- insert into CadreControl.ValidationKey (Key, StampId)
915
- with context OwnerKey = ?, Signature = ?
916
- values (?, ?)
917
- `, [ownerKey, signature, key, stampId]);
871
+ await this.execWrite(`
872
+ insert into CadreControl.ValidationKey (Key, StampId)
873
+ with context OwnerKey = ?, Signature = ?
874
+ values (?, ?)
875
+ `, [ownerKey, signature, key, stampId], 'validation-key-insert');
918
876
  log('Validation key inserted: %s', key);
919
877
  }
920
878
  /**
@@ -934,7 +892,7 @@ export class ControlDatabase {
934
892
  * when a row was actually removed.
935
893
  */
936
894
  deleteValidationKey(key, ownerKey, signMessage) {
937
- return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage));
895
+ return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage), {}, 'validation-key-delete');
938
896
  }
939
897
  /**
940
898
  * Owner-vouched INSERT of one `CadrePeer` membership row, notifying the membership
@@ -990,10 +948,10 @@ export class ControlDatabase {
990
948
  // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
991
949
  // identical to the context pair, which the AuthorizedInsert constraint binds — so a
992
950
  // reader can later re-check the voucher against its node-local trusted-owner anchor.
993
- await this.db.exec(`
994
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
995
- with context OwnerKey = ?, Signature = ?
996
- values (?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?, ?)
997
955
  `, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
998
956
  return true;
999
957
  });
@@ -1056,11 +1014,11 @@ export class ControlDatabase {
1056
1014
  const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
1057
1015
  await this.mutateCadrePeer('peer-reauthorize', async () => {
1058
1016
  // Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
1059
- await this.db.exec(`
1060
- update CadreControl.CadrePeer
1061
- with context OwnerKey = ?, Signature = ?
1062
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1063
- where PeerId = ?
1017
+ await this.db.exec(`
1018
+ update CadreControl.CadrePeer
1019
+ with context OwnerKey = ?, Signature = ?
1020
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
1021
+ where PeerId = ?
1064
1022
  `, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
1065
1023
  });
1066
1024
  return true;
@@ -1102,7 +1060,7 @@ export class ControlDatabase {
1102
1060
  * when a row was actually removed.
1103
1061
  */
1104
1062
  deleteDeviceToken(peerId, ownerKey, signMessage) {
1105
- return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage));
1063
+ return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage), {}, 'device-token-delete');
1106
1064
  }
1107
1065
  /**
1108
1066
  * Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
@@ -1151,18 +1109,18 @@ export class ControlDatabase {
1151
1109
  // cover it — the digests are domain-separated and neither replays as the other.
1152
1110
  const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
1153
1111
  await this.inTransaction(`delete ${table}`, async () => {
1154
- await this.db.exec(`
1155
- delete from CadreControl.${table}
1156
- with context OwnerKey = ?, Signature = ?
1157
- where ${GUARDED_KEY_COLUMN[table]} = ?
1112
+ await this.db.exec(`
1113
+ delete from CadreControl.${table}
1114
+ with context OwnerKey = ?, Signature = ?
1115
+ where ${GUARDED_KEY_COLUMN[table]} = ?
1158
1116
  `, [ownerKey, signature, keyValue]);
1159
1117
  // ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
1160
1118
  // than leaning on the column default — the seat-at-zero rule is load-bearing for
1161
1119
  // reissueRevocations' monotonic bump, so state it at the write site.
1162
- await this.db.exec(`
1163
- insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1164
- with context OwnerKey = ?, Signature = ?
1165
- values (?, ?, ?, 0)
1120
+ await this.db.exec(`
1121
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1122
+ with context OwnerKey = ?, Signature = ?
1123
+ values (?, ?, ?, 0)
1166
1124
  `, [ownerKey, revocationSignature, table, keyValue, stampId]);
1167
1125
  });
1168
1126
  log('%s deleted: %s (stamp retired)', table, keyValue);
@@ -1239,10 +1197,10 @@ export class ControlDatabase {
1239
1197
  log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
1240
1198
  return false;
1241
1199
  }
1242
- const sql = `
1243
- delete from CadreControl.${table}
1244
- with context OwnerKey = null, Signature = null
1245
- where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1200
+ const sql = `
1201
+ delete from CadreControl.${table}
1202
+ with context OwnerKey = null, Signature = null
1203
+ where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
1246
1204
  `;
1247
1205
  if (table === 'CadrePeer') {
1248
1206
  // Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
@@ -1259,7 +1217,7 @@ export class ControlDatabase {
1259
1217
  await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
1260
1218
  }
1261
1219
  else {
1262
- await this.execWrite(sql, [rowKey, stampId]);
1220
+ await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
1263
1221
  }
1264
1222
  // A row leaving the control plane without an owner signature at the write site
1265
1223
  // should be visible in a log.
@@ -1400,18 +1358,18 @@ export class ControlDatabase {
1400
1358
  // output minted per row incarnation (generateStampId), so it identifies one
1401
1359
  // row across all TableName values. Do NOT "fix" this by adding TableName to
1402
1360
  // the where clause.
1403
- await this.db.exec(`
1404
- update CadreControl.Revocation
1405
- with context OwnerKey = ?, Signature = ?
1406
- set ReissuedAt = ?
1407
- where StampId = ?
1361
+ await this.db.exec(`
1362
+ update CadreControl.Revocation
1363
+ with context OwnerKey = ?, Signature = ?
1364
+ set ReissuedAt = ?
1365
+ where StampId = ?
1408
1366
  `, [ownerKey, signature, reissuedAt, row.stampId]);
1409
1367
  executed++;
1410
1368
  }
1411
1369
  });
1412
1370
  log('Reissued %d revocation tombstone(s) at %d', executed, reissuedAt);
1413
1371
  return executed;
1414
- });
1372
+ }, {}, 'revocation-reissue');
1415
1373
  }
1416
1374
  /**
1417
1375
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
@@ -1514,7 +1472,7 @@ export class ControlDatabase {
1514
1472
  this.assertCommitBoundary(reason, 'on return from');
1515
1473
  await this.notifyMembershipChanged(reason);
1516
1474
  return result;
1517
- });
1475
+ }, {}, reason);
1518
1476
  }
1519
1477
  /**
1520
1478
  * Run one local write under the database-wide write lock, serializing it against
@@ -1569,10 +1527,8 @@ export class ControlDatabase {
1569
1527
  *
1570
1528
  * The retry wraps the LOCK, it does not live inside it: each attempt takes and
1571
1529
  * releases the lock, and the backoff sleeps with NO lock held, so a write parked in
1572
- * backoff never stalls the other local writers queued behind it. Same reason
1573
- * {@link withUseNumberRetry} re-takes the lock per attempt — see the note on
1574
- * `USE_NUMBER_ATTEMPTS`. Safe because every locked body is atomic and re-runnable
1575
- * (the contract on {@link withWriteLock}).
1530
+ * backoff never stalls the other local writers queued behind it. Safe because every
1531
+ * locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
1576
1532
  *
1577
1533
  * Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
1578
1534
  * `initialized` is set — so it reaches this method directly rather than through
@@ -1581,9 +1537,16 @@ export class ControlDatabase {
1581
1537
  * rather than carving out a lockless path. It is also the ONLY caller that passes a
1582
1538
  * `policy` — everything else takes the default, whose classifier and attempt count are
1583
1539
  * unchanged by that call site existing.
1540
+ *
1541
+ * `label` names the operation in this loop's debug lines and nothing else
1542
+ * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
1543
+ * several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
1544
+ * attributed to a write — keep new call sites labelled.
1584
1545
  */
1585
- lockedWithRetry(fn, policy = {}) {
1586
- return retryControlWrite(() => this.withWriteLock(fn), { ...policy, ...this.controlWriteRetryPacing });
1546
+ lockedWithRetry(fn, policy = {}, label) {
1547
+ // The label lands LAST so a call site's own name survives both the policy and the
1548
+ // spec-injected pacing; it changes log attribution only, never behaviour.
1549
+ return retryControlWrite(() => this.withWriteLock(fn), { ...policy, ...this.controlWriteRetryPacing, ...(label !== undefined ? { label } : {}) });
1587
1550
  }
1588
1551
  /**
1589
1552
  * One-statement local write, serialized by {@link withWriteLock} and retried on a
@@ -1594,10 +1557,13 @@ export class ControlDatabase {
1594
1557
  * Public so `SeedBootstrapService`'s direct `CadrePeer`/`DeviceToken` SQL writes go
1595
1558
  * through the same seam. Must NOT be called from inside an already-locked body (the
1596
1559
  * lock is not re-entrant); such a caller uses a bare `getDatabase().exec` instead.
1560
+ *
1561
+ * `label` names the write in the retry loop's debug lines (see {@link lockedWithRetry});
1562
+ * optional only because it is log-only, but every caller in this repo passes one.
1597
1563
  */
1598
- execWrite(sql, params) {
1564
+ execWrite(sql, params, label) {
1599
1565
  this.ensureInitialized();
1600
- return this.lockedWithRetry(() => this.db.exec(sql, params));
1566
+ return this.lockedWithRetry(() => this.db.exec(sql, params), {}, label);
1601
1567
  }
1602
1568
  /**
1603
1569
  * Throw unless the database sits at a committed boundary — `getAutocommit()` is false
@@ -1711,10 +1677,10 @@ export class ControlDatabase {
1711
1677
  // Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
1712
1678
  // signed source-of-truth and the stored value are produced once. StampId is a real,
1713
1679
  // unique column (single-use anti-replay), no longer a context value.
1714
- await this.execWrite(`
1715
- insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
1716
- with context OwnerKey = ?, Signature = ?
1717
- values (?, ?, ?, ?, ?, ?, ?)
1680
+ await this.execWrite(`
1681
+ insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
1682
+ with context OwnerKey = ?, Signature = ?
1683
+ values (?, ?, ?, ?, ?, ?, ?)
1718
1684
  `, [
1719
1685
  ownerKey, signature,
1720
1686
  token, sAppId,
@@ -1723,7 +1689,7 @@ export class ControlDatabase {
1723
1689
  options.validationUrl ?? null,
1724
1690
  options.strandId ?? null,
1725
1691
  stampId,
1726
- ]);
1692
+ ], 'formation-invite-insert');
1727
1693
  log('Formation invite inserted: %s', token);
1728
1694
  }
1729
1695
  /**
@@ -1751,50 +1717,60 @@ export class ControlDatabase {
1751
1717
  * takes an owner re-seat ({@link insertStrand}) plus a bound invite, never another
1752
1718
  * redemption.
1753
1719
  *
1754
- * `UseNumber` is computed as `max(UseNumber)+1` for the token (the `Monotonic`
1755
- * constraint), inside the write lock and re-read per attempt by
1756
- * {@link withUseNumberRetry} — so concurrent redemptions of one token no longer need
1757
- * the caller to serialise them, and a use number lost to another writer is retried here
1758
- * with the caller's parameters passed through UNCHANGED. That is what makes the retry
1759
- * safe: the approval is bound to the nonce, not to the use number, so re-presenting it
1760
- * cannot drift, and no second trip through the approval hook is possible from in here.
1720
+ * The usage row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so
1721
+ * concurrent redemptions of one token never contend for a shared row key — there is no
1722
+ * lost race to retry and no second trip through the approval hook. The invite's seat
1723
+ * budget is checked by COUNT ({@link assertSeatRemains}) inside the write lock, ahead of
1724
+ * the write, so a spent invite is refused by name (`InvitationExhaustedError`) instead of
1725
+ * as the schema cap clause's generic `Authorized` CHECK failure.
1761
1726
  */
1762
1727
  async redeemInvitation(params) {
1763
1728
  this.ensureInitialized();
1764
1729
  const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
1765
1730
  log('Redeeming invitation %s -> strand %s', token, strandId);
1766
1731
  const localPeerId = this.config.libp2pNode.peerId.toString();
1767
- // Minted ONCE, outside the loop: a failed attempt rolls its whole transaction back, so
1768
- // nothing was ever persisted under this stamp, and it is deliberately not part of the
1769
- // approver's signed digest. Re-minting per attempt would only churn.
1732
+ // A failed attempt rolls its whole transaction back, so nothing was ever persisted under
1733
+ // this stamp, and it is deliberately not part of the approver's signed digest.
1770
1734
  const strandStampId = generateStampId(localPeerId);
1771
- const useNumber = await this.withUseNumberRetry(token, 'redemption', signal, totalUses, attemptUseNumber => this.inTransaction('redemption', async () => {
1772
- // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
1773
- // still carrying a fresh unique StampId for the anti-replay column.
1774
- // Hard-coded open + keyless: the consent branch admits no other shape.
1775
- await this.db.exec(`
1776
- insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
1777
- with context OwnerKey = null, Signature = null
1778
- values (?, 'o', null, ?)
1735
+ // Ordering inside the locked body is contractual: the abort check, then the seat check,
1736
+ // then the write — FormationAbortedError may only ever be thrown before the write is
1737
+ // issued, and a transient-failure retry (lockedWithRetry re-runs the whole body) must
1738
+ // re-check both before re-presenting the write.
1739
+ await this.lockedWithRetry(async () => {
1740
+ // Inside the lock: a write still parked behind another writer when the caller's
1741
+ // budget expired is abandoned rather than executed.
1742
+ if (signal?.aborted) {
1743
+ throw new FormationAbortedError(token, 'redemption');
1744
+ }
1745
+ await this.assertSeatRemains(token, totalUses);
1746
+ await this.inTransaction('redemption', async () => {
1747
+ // 1. Strand row — authorised by the FormationUsage branch (no owner sig),
1748
+ // 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, ?)
1779
1754
  `, [strandId, strandStampId]);
1780
- // 2. FormationUsage row — authorised by the matching FormationInvite, and
1781
- // carrying the strand's stamp so it authorizes THIS row and no other.
1782
- await this.execFormationUsageInsert({
1783
- token, useNumber: attemptUseNumber, usageStampId, disclosure, strandId, strandStampId,
1784
- peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
1785
- validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
1755
+ // 2. FormationUsage row — authorised by the matching FormationInvite, and
1756
+ // carrying the strand's stamp so it authorizes THIS row and no other.
1757
+ await this.execFormationUsageInsert({
1758
+ token, usageStampId, disclosure, strandId, strandStampId,
1759
+ peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
1760
+ validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
1761
+ });
1786
1762
  });
1787
- }));
1788
- log('Redeemed invitation %s -> strand %s (use #%d)', token, strandId, useNumber);
1789
- return { useNumber, usageStampId };
1763
+ }, {}, 'formation-usage');
1764
+ log('Redeemed invitation %s -> strand %s (stamp %s)', token, strandId, usageStampId);
1765
+ return { usageStampId };
1790
1766
  }
1791
1767
  /**
1792
1768
  * Record a `FormationUsage` against an **already-existing** `Strand` (no strand
1793
1769
  * insert). This is the redemption path when the strand was provisioned
1794
1770
  * separately (e.g. owner-signed) and the consent record is added after the
1795
1771
  * fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
1796
- * is satisfied by the pre-existing committed strand row. Returns the assigned
1797
- * `UseNumber`.
1772
+ * is satisfied by the pre-existing committed strand row. Echoes back the
1773
+ * redemption's `usageStampId` (the row's primary key).
1798
1774
  *
1799
1775
  * Use {@link redeemInvitation} instead when the strand must be created by
1800
1776
  * consent atomically with the usage.
@@ -1808,108 +1784,47 @@ export class ControlDatabase {
1808
1784
  * by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
1809
1785
  * point is a genuine race and deserves a named error, not a silent rollback.
1810
1786
  *
1811
- * `UseNumber` is read inside the write lock and re-read per attempt by
1812
- * {@link withUseNumberRetry}: this is the path production actually races on (a bound
1813
- * invite published by `cadre-web` / `cadre-phone` is always record-only), and a use number
1814
- * lost to another writer is retried here with the caller's parameters — nonce, approver
1815
- * sign-off, strand, peer key, disclosure — passed through UNCHANGED, so a granted approval
1816
- * is never thrown away for a second trip through the approval hook.
1787
+ * The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
1788
+ * path — the one production actually races on (a bound invite published by `cadre-web` /
1789
+ * `cadre-phone` is always record-only) — never contends with another redemption for a
1790
+ * shared row key: there is no lost race to retry and no second trip through the approval
1791
+ * hook. The invite's seat budget is checked by COUNT ({@link assertSeatRemains}) inside
1792
+ * the write lock, ahead of the write.
1817
1793
  */
1818
1794
  async recordFormationUsage(params) {
1819
1795
  this.ensureInitialized();
1820
1796
  const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
1821
- // Read ONCE, outside the loop: the host strand is pre-existing and owner-signed, and a
1822
- // retry changes which use number we take, never which strand row we name.
1797
+ // The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
1798
+ // consent record authorizes.
1823
1799
  const strandStampId = await this.queryStrandStampId(strandId);
1824
1800
  if (strandStampId === null) {
1825
1801
  throw new MissingHostStrandError(strandId, token);
1826
1802
  }
1827
- const useNumber = await this.withUseNumberRetry(token, 'usage recording', signal, totalUses, attemptUseNumber => this.execFormationUsageInsert({
1828
- token, useNumber: attemptUseNumber, usageStampId, disclosure, strandId, strandStampId,
1829
- peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
1830
- validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
1831
- }));
1832
- log('Recorded formation usage: token=%s strand=%s (use #%d)', token, strandId, useNumber);
1833
- return { useNumber, usageStampId };
1834
- }
1835
- /**
1836
- * Assign a `UseNumber` and run `write` under it, re-picking and retrying when another
1837
- * writer took that number first.
1838
- *
1839
- * Each attempt takes {@link withWriteLock} and, inside it, reads {@link nextUseNumber}
1840
- * afresh. Reading under the lock is the whole fix for the common case: two redemptions of
1841
- * one token on the SAME node are serialized by the local write queue, so each computes a
1842
- * number the other has already committed and neither collides. The retry covers the writers
1843
- * this lock does not reach — another node of the cadre, another `Database` handle over the
1844
- * same store — which can still take the number between our read and our commit.
1845
- *
1846
- * `write` receives ONLY the use number; every other parameter of the redemption (the nonce,
1847
- * the approver's signature, the strand, the joining peer key, the disclosure) is closed over
1848
- * by the caller and passed through untouched. So by construction a retry re-presents the
1849
- * IDENTICAL approval and the four non-nonce fields inside the approver's signed digest
1850
- * cannot drift. No second call to the approval hook is reachable from in here either:
1851
- * `obtainApproval` lives a layer up in `ControlFormationUsageRecorder` and is never
1852
- * re-entered.
1853
- *
1854
- * `write` must be a BARE private body ({@link inTransaction} /
1855
- * {@link execFormationUsageInsert}) — {@link withWriteLock} is not re-entrant and a `write`
1856
- * that called another locked public method would hang silently and strand the write queue.
1857
- *
1858
- * A failed attempt has written nothing (a bare insert never landed; a transactional one
1859
- * rolled back), so the abort check at the top of each attempt stays inside
1860
- * {@link FormationAbortedError}'s contract — it is only ever thrown before this attempt's
1861
- * write is issued.
1862
- *
1863
- * @param operation - `'redemption'` | `'usage recording'`, for the abort error's text.
1864
- * @param knownTotalUses - The invite's seat budget when the caller already has it in hand
1865
- * (`undefined` to fall back to a fresh {@link queryFormationInvite} read; `null` for "no
1866
- * invite row" / unlimited). Passed through to {@link assertSeatRemains} on every attempt.
1867
- * @returns The `UseNumber` the successful attempt wrote under.
1868
- */
1869
- async withUseNumberRetry(token, operation, signal, knownTotalUses, write) {
1870
- let lastError;
1871
- for (let attempt = 1; attempt <= USE_NUMBER_ATTEMPTS; attempt++) {
1872
- try {
1873
- // lockedWithRetry carries the transient-cluster retry INSIDE this use-number
1874
- // retry. The loops cannot multiply: a lost use number is a constraint failure,
1875
- // which `isRetriableControlWriteFailure` never matches, and a transient cluster
1876
- // failure is not a lost use number, so exactly one loop claims any given error.
1877
- return await this.lockedWithRetry(async () => {
1878
- // Inside the lock: a write still parked behind another writer when the caller's
1879
- // budget expired is abandoned rather than executed.
1880
- if (signal?.aborted) {
1881
- throw new FormationAbortedError(token, operation);
1882
- }
1883
- const useNumber = await this.nextUseNumber(token);
1884
- await this.assertSeatRemains(token, useNumber, knownTotalUses);
1885
- await write(useNumber);
1886
- return useNumber;
1887
- });
1888
- }
1889
- catch (error) {
1890
- if (!isLostUseNumberRace(error)) {
1891
- throw error;
1892
- }
1893
- lastError = error;
1894
- log('Use number for token %s was taken by another writer (attempt %d/%d): %s', token, attempt, USE_NUMBER_ATTEMPTS, error);
1803
+ // Ordering inside the locked body is contractual — abort check, seat check, write — see
1804
+ // the matching note in redeemInvitation.
1805
+ await this.lockedWithRetry(async () => {
1806
+ if (signal?.aborted) {
1807
+ throw new FormationAbortedError(token, 'usage recording');
1895
1808
  }
1896
- }
1897
- // Out of attempts under sustained contention. Rethrowing the engine's own error keeps
1898
- // today's behaviour: `StrandFormationManager.provisionAsResponder`'s catch-all maps it to
1899
- // `Formation conflict, retry` and the joiner starts over with a fresh nonce.
1900
- throw lastError;
1809
+ await this.assertSeatRemains(token, totalUses);
1810
+ await this.execFormationUsageInsert({
1811
+ token, usageStampId, disclosure, strandId, strandStampId,
1812
+ peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
1813
+ validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
1814
+ });
1815
+ }, {}, 'formation-usage');
1816
+ log('Recorded formation usage: token=%s strand=%s (stamp %s)', token, strandId, usageStampId);
1817
+ return { usageStampId };
1901
1818
  }
1902
1819
  /**
1903
- * Refuse an attempt that would consume a seat the invite does not have.
1820
+ * Refuse a redemption that would consume a seat the invite does not have.
1904
1821
  *
1905
- * `FormationUsage.Authorized` already enforces `FI.TotalUses is null or FI.TotalUses >=
1906
- * new.UseNumber`, so an over-limit write fails at the database anyway — but it fails as a
1907
- * generic `CHECK constraint failed: Authorized`, which the manager reports as a retryable
1908
- * conflict. Catching it here as {@link InvitationExhaustedError} lets the joiner be told the
1909
- * invitation is spent instead of being sent around a loop that can never close. Run on EVERY
1910
- * attempt, including the first: two redemptions racing on the SAME node serialize behind the
1911
- * local write queue, so the loser's first attempt is already over budget, with no retry
1912
- * involved.
1822
+ * The schema's count-based cap clause (`FormationUsage.Authorized`) refuses an over-limit
1823
+ * write at the database anyway — but it fails as a generic `CHECK constraint failed:
1824
+ * Authorized`, which the manager reports as a retryable conflict. Catching it here as
1825
+ * {@link InvitationExhaustedError} lets the joiner be told the invitation is spent instead
1826
+ * of being sent around a loop that can never close. Runs inside the write lock, so on a
1827
+ * same-node race the loser reads the winner's committed row and is refused by name.
1913
1828
  *
1914
1829
  * `knownTotalUses` lets a caller that already read the `FormationInvite` row (both
1915
1830
  * production callers in `ControlFormationUsageRecorder` do) skip a second read on the common,
@@ -1919,9 +1834,9 @@ export class ControlDatabase {
1919
1834
  * invite, before an outbound approval call). Safe today because `FormationInvite` is
1920
1835
  * insert/delete only (its `Immutable` constraint), so the only way to stale it is an owner
1921
1836
  * revoking the token and re-issuing it with MORE seats mid-redemption; if invites ever gain
1922
- * an update path, drop the parameter and read here on every attempt instead.
1837
+ * an update path, drop the parameter and read here instead.
1923
1838
  */
1924
- async assertSeatRemains(token, useNumber, knownTotalUses) {
1839
+ async assertSeatRemains(token, knownTotalUses) {
1925
1840
  // A missing invite is left to `Authorized` — it is not an exhaustion, and the CHECK's
1926
1841
  // rejection is already the right, non-retryable answer for it. That fallback is also what
1927
1842
  // makes this read's failure mode safe: it is a full-primary-key point lookup
@@ -1932,8 +1847,12 @@ export class ControlDatabase {
1932
1847
  const totalUses = knownTotalUses !== undefined
1933
1848
  ? knownTotalUses
1934
1849
  : (await this.queryFormationInvite(token))?.totalUses ?? null;
1935
- if (totalUses != null && useNumber > totalUses) {
1936
- throw new InvitationExhaustedError(token, useNumber, totalUses);
1850
+ if (totalUses == null) {
1851
+ return;
1852
+ }
1853
+ const used = await this.countFormationUsage(token);
1854
+ if (used >= totalUses) {
1855
+ throw new InvitationExhaustedError(token, used, totalUses);
1937
1856
  }
1938
1857
  }
1939
1858
  /** Parameterised `FormationUsage` insert shared by redeem + record paths. */
@@ -1947,13 +1866,13 @@ export class ControlDatabase {
1947
1866
  // robustness/consistency change, matching the strand layer's `consumeInvite`,
1948
1867
  // not a fix for an observable mis-ordering.
1949
1868
  const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
1950
- await this.db.exec(`
1951
- insert into CadreControl.FormationUsage (Token, UseNumber, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
1952
- with context Now = ?, ValidationKey = ?, ValidationSignature = ?
1953
- values (?, ?, ?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?, ?, ?, ?)
1954
1873
  `, [
1955
1874
  nowCanonical, opts.validationKey, opts.validationSignature,
1956
- opts.token, opts.useNumber, opts.usageStampId, opts.peerKey,
1875
+ opts.token, opts.usageStampId, opts.peerKey,
1957
1876
  opts.peerSignature, opts.disclosure, opts.strandId, opts.strandStampId,
1958
1877
  ]);
1959
1878
  }
@@ -1978,6 +1897,22 @@ export class ControlDatabase {
1978
1897
  }
1979
1898
  /**
1980
1899
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
1900
+ *
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.
1981
1916
  */
1982
1917
  async countFormationUsage(token) {
1983
1918
  this.ensureInitialized();
@@ -2035,19 +1970,6 @@ export class ControlDatabase {
2035
1970
  }
2036
1971
  return false;
2037
1972
  }
2038
- /**
2039
- * Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint.
2040
- *
2041
- * Called only from inside {@link withUseNumberRetry}'s locked body — a read takes no
2042
- * transaction of its own, so it is safe there, and reading it under the lock is what stops
2043
- * two local redemptions of one token from picking the same number.
2044
- */
2045
- async nextUseNumber(token) {
2046
- for await (const row of this.db.eval('select coalesce(max(UseNumber), 0) as MaxUse from CadreControl.FormationUsage where Token = ?', [token])) {
2047
- return row.MaxUse + 1;
2048
- }
2049
- return 1;
2050
- }
2051
1973
  /**
2052
1974
  * Close the database and cleanup resources.
2053
1975
  *