@serfab/cadre-core 0.13.0 → 1.1.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 (131) hide show
  1. package/README.md +20 -6
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +558 -80
  7. package/dist/cadre-node.js +1060 -170
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +299 -44
  16. package/dist/control-database.js +469 -69
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +103 -19
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +176 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store.js +2 -2
  31. package/dist/index.d.ts +14 -7
  32. package/dist/index.js +33 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/node-local-snapshot.d.ts +15 -7
  35. package/dist/node-local-snapshot.js +23 -8
  36. package/dist/node-local-snapshot.js.map +1 -1
  37. package/dist/peer-dial.d.ts +143 -0
  38. package/dist/peer-dial.js +176 -0
  39. package/dist/peer-dial.js.map +1 -0
  40. package/dist/relay-addrs.d.ts +90 -52
  41. package/dist/relay-addrs.js +91 -59
  42. package/dist/relay-addrs.js.map +1 -1
  43. package/dist/relay-reservation.d.ts +71 -3
  44. package/dist/relay-reservation.js +116 -8
  45. package/dist/relay-reservation.js.map +1 -1
  46. package/dist/seed-bootstrap.d.ts +30 -1
  47. package/dist/seed-bootstrap.js +62 -17
  48. package/dist/seed-bootstrap.js.map +1 -1
  49. package/dist/storage-scope.d.ts +62 -0
  50. package/dist/storage-scope.js +75 -0
  51. package/dist/storage-scope.js.map +1 -0
  52. package/dist/strand-database.d.ts +34 -16
  53. package/dist/strand-database.js +38 -24
  54. package/dist/strand-database.js.map +1 -1
  55. package/dist/strand-first-sync-gate.d.ts +152 -0
  56. package/dist/strand-first-sync-gate.js +208 -0
  57. package/dist/strand-first-sync-gate.js.map +1 -0
  58. package/dist/strand-formation-manager.d.ts +87 -5
  59. package/dist/strand-formation-manager.js +109 -5
  60. package/dist/strand-formation-manager.js.map +1 -1
  61. package/dist/strand-formation-protocol.d.ts +21 -1
  62. package/dist/strand-formation-protocol.js +30 -0
  63. package/dist/strand-formation-protocol.js.map +1 -1
  64. package/dist/strand-instance-manager.d.ts +243 -6
  65. package/dist/strand-instance-manager.js +470 -37
  66. package/dist/strand-instance-manager.js.map +1 -1
  67. package/dist/strand-member-key.d.ts +20 -12
  68. package/dist/strand-member-key.js +20 -12
  69. package/dist/strand-member-key.js.map +1 -1
  70. package/dist/strand-member-registry.js +3 -0
  71. package/dist/strand-member-registry.js.map +1 -1
  72. package/dist/strand-membership-reconciler.d.ts +361 -0
  73. package/dist/strand-membership-reconciler.js +548 -0
  74. package/dist/strand-membership-reconciler.js.map +1 -0
  75. package/dist/strand-membership-writer.d.ts +131 -11
  76. package/dist/strand-membership-writer.js +396 -134
  77. package/dist/strand-membership-writer.js.map +1 -1
  78. package/dist/strand-network-config.d.ts +46 -14
  79. package/dist/strand-network-config.js +79 -23
  80. package/dist/strand-network-config.js.map +1 -1
  81. package/dist/strand-solicitation.d.ts +10 -1
  82. package/dist/strand-solicitation.js +5 -0
  83. package/dist/strand-solicitation.js.map +1 -1
  84. package/dist/strand-wake-protocol.d.ts +11 -7
  85. package/dist/strand-wake-protocol.js +20 -52
  86. package/dist/strand-wake-protocol.js.map +1 -1
  87. package/dist/strand-watcher.js +6 -0
  88. package/dist/strand-watcher.js.map +1 -1
  89. package/dist/timed-step.d.ts +12 -0
  90. package/dist/timed-step.js +28 -0
  91. package/dist/timed-step.js.map +1 -0
  92. package/dist/timeout-scheduler.d.ts +19 -0
  93. package/dist/timeout-scheduler.js +22 -0
  94. package/dist/timeout-scheduler.js.map +1 -0
  95. package/dist/types.d.ts +262 -30
  96. package/dist/types.js.map +1 -1
  97. package/package.json +8 -8
  98. package/src/bootstrap-peer-store-file.ts +2 -2
  99. package/src/bootstrap-peer-store.ts +69 -27
  100. package/src/cadre-node.ts +7301 -6353
  101. package/src/control-authorization.ts +2 -1
  102. package/src/control-cohort.ts +122 -130
  103. package/src/control-database.ts +537 -69
  104. package/src/control-read-retry.ts +202 -167
  105. package/src/control-retry.ts +297 -190
  106. package/src/control-schema.ts +103 -19
  107. package/src/control-write-retry.ts +533 -383
  108. package/src/enrolled-machine-store.ts +2 -2
  109. package/src/index.ts +83 -6
  110. package/src/node-local-snapshot.ts +25 -8
  111. package/src/peer-dial.ts +215 -0
  112. package/src/relay-addrs.ts +458 -436
  113. package/src/relay-reservation.ts +951 -828
  114. package/src/seed-bootstrap.ts +76 -18
  115. package/src/storage-scope.ts +78 -0
  116. package/src/strand-database.ts +50 -27
  117. package/src/strand-first-sync-gate.ts +258 -0
  118. package/src/strand-formation-manager.ts +140 -5
  119. package/src/strand-formation-protocol.ts +43 -1
  120. package/src/strand-instance-manager.ts +577 -37
  121. package/src/strand-member-key.ts +20 -12
  122. package/src/strand-member-registry.ts +3 -0
  123. package/src/strand-membership-reconciler.ts +680 -0
  124. package/src/strand-membership-writer.ts +527 -189
  125. package/src/strand-network-config.ts +102 -24
  126. package/src/strand-solicitation.ts +16 -1
  127. package/src/strand-wake-protocol.ts +21 -66
  128. package/src/strand-watcher.ts +6 -0
  129. package/src/timed-step.ts +28 -0
  130. package/src/timeout-scheduler.ts +28 -0
  131. package/src/types.ts +1789 -1554
@@ -7,7 +7,7 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
7
7
  import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
8
8
  import type { Libp2p } from '@libp2p/interface';
9
9
  import type { IRepo } from '@optimystic/db-core';
10
- import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
10
+ import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow, PushPlatform } from './types.js';
11
11
  import { CONTROL_SCHEMA } from './control-schema.js';
12
12
  import { canonicalDatetime } from './canonical-datetime.js';
13
13
  import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
@@ -15,9 +15,10 @@ import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from
15
15
  import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
16
16
  import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
17
17
  import type { ControlWriteRetryOptions } from './control-write-retry.js';
18
- import { retryControlRead } from './control-read-retry.js';
18
+ import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
19
19
  import type { ControlReadRetryOptions } from './control-read-retry.js';
20
20
  import { chainMessages } from './control-retry.js';
21
+ import type { ControlRetryAbandonment } from './control-retry.js';
21
22
 
22
23
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
23
24
 
@@ -291,6 +292,7 @@ const GUARDED_KEY_COLUMN: Readonly<Record<RevocableTable, GuardedKeyColumn>> = {
291
292
  CadrePeer: 'PeerId',
292
293
  ValidationKey: 'Key',
293
294
  Strand: 'Id',
295
+ StrandPartyKey: 'Id',
294
296
  DeviceToken: 'PeerId',
295
297
  };
296
298
 
@@ -320,20 +322,48 @@ const STRAND_ID_CONFLICT = /UNIQUE constraint failed: Strand\.Id\b/i;
320
322
  * reword reddens there.
321
323
  */
322
324
  export function isStrandIdConflict(error: unknown): boolean {
323
- if (!(error instanceof Error)) {
324
- return false;
325
- }
326
- return chainMessages(error).some(message => STRAND_ID_CONFLICT.test(message));
325
+ return errorChainMatches(error, STRAND_ID_CONFLICT);
326
+ }
327
+
328
+ /**
329
+ * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
330
+ * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
331
+ * block. The schema's table comment says why that matters and why the row can never read
332
+ * as a retirement; `RowIsGone` pins this exact triple.
333
+ */
334
+ export const REVOCATION_LEDGER_MARKER = {
335
+ tableName: 'Revocation',
336
+ rowKey: 'ledger',
337
+ stampId: 'opened',
338
+ } as const;
339
+
340
+ /**
341
+ * The rejection a second insert of the ledger marker produces: the optimystic vtab's
342
+ * primary-key wording (see {@link STRAND_ID_CONFLICT}), naming both columns of
343
+ * `Revocation`'s composite key. Applied only to the marker's own insert, whose key
344
+ * ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
345
+ * has no other unique constraint.
346
+ */
347
+ const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
348
+
349
+ /** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
350
+ function isRevocationLedgerConflict(error: unknown): boolean {
351
+ return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
352
+ }
353
+
354
+ /** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
355
+ function errorChainMatches(error: unknown, pattern: RegExp): boolean {
356
+ return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
327
357
  }
328
358
 
329
359
  /**
330
360
  * Guarded tables a node may reap locally once their tombstone has committed — the
331
361
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
332
- * `CadrePeer.AuthorizedDelete` in the schema). `Strand` is deliberately absent: its row
333
- * carries `MemberPrivateKey`, the party's own membership secret for that network, stored
334
- * nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any future change).
335
- * `OwnerKey` has no production removal path and `MinOneOwner` makes an automated
336
- * owner-key reap a party-bricking hazard.
362
+ * `CadrePeer.AuthorizedDelete` in the schema). `Strand` and `StrandPartyKey` are
363
+ * deliberately absent: their rows carry `MemberPrivateKey` / `PrivateKey` — party
364
+ * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
365
+ * future change). `OwnerKey` has no production removal path and `MinOneOwner` makes an
366
+ * automated owner-key reap a party-bricking hazard.
337
367
  */
338
368
  export const REAPABLE_TABLES = ['CadrePeer', 'DeviceToken', 'ValidationKey'] as const satisfies readonly RevocableTable[];
339
369
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
@@ -379,6 +409,20 @@ export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
379
409
  */
380
410
  export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
381
411
 
412
+ /**
413
+ * Notified when the control-write retry funnel GIVES UP on a local control write — the
414
+ * classifier declined the failure as non-transient, or every attempt / the elapsed budget
415
+ * ran out. Carries the operation label, how far it got and the error
416
+ * ({@link ControlRetryAbandonment}).
417
+ *
418
+ * The seam that stops an abandoned BACKGROUND write from disappearing: those are `void`-ed
419
+ * with a `debug`-only catch, and that namespace is off unless somebody set `DEBUG=`, so
420
+ * without this the write, the operator and the embedding app all learn nothing. Synchronous
421
+ * and must not throw — the retry loop swallows and logs if it does, because the write's own
422
+ * failure is what has to reach the caller.
423
+ */
424
+ export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
425
+
382
426
  export interface ControlDatabaseConfig {
383
427
  /** Party ID for the control network */
384
428
  partyId: string;
@@ -405,8 +449,16 @@ export class ControlDatabase {
405
449
  private initialized = false;
406
450
  private membershipListener: MembershipChangeListener | null = null;
407
451
  private guardedDeleteListener: GuardedDeleteListener | null = null;
452
+ private controlWriteAbandonedListener: ControlWriteAbandonedListener | null = null;
408
453
  /** Tail of the local-write chain — see {@link withWriteLock}. */
409
454
  private writeQueue: Promise<unknown> = Promise.resolve();
455
+ /**
456
+ * Locked write bodies currently running (see {@link runWriteBody}); what
457
+ * {@link readRowsOnce} consults to route an unlocked read around a write that has
458
+ * started but not yet opened its transaction. At most 1 while every writer takes the
459
+ * lock — a count rather than a flag so it stays truthful if that ever stops holding.
460
+ */
461
+ private runningWriteBodies = 0;
410
462
  /**
411
463
  * Pacing seams for {@link lockedWithRetry}. Production leaves this empty (real backoff,
412
464
  * real clock); specs inject a recorded `sleep` / fake `now` so no test waits out a real
@@ -507,8 +559,8 @@ export class ControlDatabase {
507
559
  // NOTE: this is where a slow launch is felt. Duration is (raw-storage operations
508
560
  // issued) × per-operation storage latency: ~1ms/op on an idle machine, but
509
561
  // 50-90ms/op on a loaded disk or a phone's flash under launch contention. A cold
510
- // start now reaches the backend 172 times (8 tables + 1 index, 21 distinct blocks
511
- // — dominated by its 130 genuine writes), a warm restart 52, because cadre-core
562
+ // start now reaches the backend 45 times (9 tables + 1 index, 20 distinct blocks
563
+ // — dominated by its genuine writes), a warm restart 13, because cadre-core
512
564
  // wraps every embedder storage in `@optimystic/db-p2p`'s write-through cache
513
565
  // (@serfab/quereus-plugin-sereus's cached-storage.ts). Uncached the same start issued ~2000 operations — the
514
566
  // upstream re-read amplification measured in
@@ -629,11 +681,33 @@ export class ControlDatabase {
629
681
  * opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
630
682
  * publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
631
683
  *
632
- * `getAutocommit()` reports the whole `Database` rather than this call (the same
633
- * property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
634
- * false means some writer's transaction is open, which is the only situation where a
635
- * read would have blocked. The check races that writer harmlessly in both directions
636
- * — losing it either reads normally (today's behaviour) or reads committed state.
684
+ * "A write is in flight" is {@link writeInFlight}, and it needs TWO signals because a
685
+ * write is invisible to `getAutocommit()` for part of its life. `Database.exec` takes
686
+ * the exec mutex first; the implicit transaction — the moment `getAutocommit()` turns
687
+ * false — opens only after the write has acquired the mutex, planned the statement,
688
+ * and awaited `begin()` on every connection. While the write WAITS for the mutex (a
689
+ * serialized read holds it, say) it still reports autocommit, so a read routed on
690
+ * `getAutocommit()` alone queued behind the write and answered only after its whole
691
+ * commit — tens of seconds against a slow cohort member, which is what timed out the
692
+ * degraded-cohort scenario's `isMember` read (verified against the engine directly;
693
+ * `complete/control-read-queues-behind-a-write-waiting-for-the-database`). So an
694
+ * UNLOCKED read also takes the committed path while any locked write body is running
695
+ * ({@link runningWriteBodies}), which covers that queued window and everything else a
696
+ * body does before its transaction opens. A read issued INSIDE a locked body
697
+ * (`underWriteLock`) must not: that body is the one being counted, and its guard reads
698
+ * need the refreshing path — so it keeps the transaction-only test.
699
+ *
700
+ * Both signals are sampled synchronously just before `eval`, which claims its place
701
+ * on the mutex in that same tick, so the only race left is a body starting or ending
702
+ * in between: a body that starts after the sample queues its statements BEHIND this
703
+ * read, and one that ended before it has nothing left to queue behind.
704
+ *
705
+ * Residual gaps, both of which still queue a read behind a write:
706
+ * - an EXPLICIT transaction ({@link inTransaction}) disqualifies the committed path in
707
+ * Quereus itself, so a read overlapping such a body's `COMMIT` falls back to the
708
+ * serialized path and waits for it;
709
+ * - a writer that bypasses {@link withWriteLock} is not counted (in this repo only
710
+ * `reference-app-web`'s deliberately-rejected diagnostics insert does).
637
711
  *
638
712
  * What a committed read gives up is observing a write that has not finished
639
713
  * committing, and that is safe for every caller here: an AWAITED write is committed by
@@ -641,14 +715,23 @@ export class ControlDatabase {
641
715
  * open explicit transaction and stays serialized, so it still sees its own
642
716
  * transaction's rows. A read after an UNAWAITED write on this database would be wrong
643
717
  * — no such caller exists, and {@link withWriteLock} is what keeps it that way.
718
+ *
719
+ * NOTE: counting whole bodies widens the stale (non-refreshing) window from "a
720
+ * transaction is open" to "a locked body is running", which adds a body's pre-transaction
721
+ * work and its post-commit tail (a `CadrePeer` write's membership-listener read). The
722
+ * slow part of a write against a degraded cohort is its commit, already inside the old
723
+ * window, so the added time is short; if a poller for replicated rows ever starves
724
+ * behind back-to-back local writes, narrow the count to the span from a body's first
725
+ * statement to its transaction opening.
644
726
  */
645
- private async readRowsOnce(sql: string, params?: SqlParameters): Promise<Record<string, SqlValue>[]> {
727
+ private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<Record<string, SqlValue>[]> {
646
728
  let iterator: AsyncIterableIterator<Record<string, SqlValue>>;
647
- if (this.db!.getAutocommit()) {
648
- iterator = this.db!.eval(sql, params);
649
- } else {
650
- log('read-eval: a write is in flight, asking for a committed read: %s', sql);
729
+ if (this.writeInFlight(underWriteLock)) {
730
+ log('read-eval: a write is in flight (%s), asking for a committed read: %s',
731
+ this.db!.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
651
732
  iterator = this.db!.eval(sql, params, { readConcurrency: 'committed' });
733
+ } else {
734
+ iterator = this.db!.eval(sql, params);
652
735
  }
653
736
  const rows: Record<string, SqlValue>[] = [];
654
737
  for await (const row of iterator) {
@@ -657,6 +740,20 @@ export class ControlDatabase {
657
740
  return rows;
658
741
  }
659
742
 
743
+ /**
744
+ * Whether a control read should ask for a committed read — see {@link readRowsOnce}.
745
+ * `getAutocommit()` reports the whole `Database` (the property
746
+ * {@link assertCommitBoundary} leans on), so false means some writer's transaction is
747
+ * open. An unlocked read also counts a running locked body that has not opened its
748
+ * transaction yet; a read inside such a body does not, since it is that body.
749
+ */
750
+ private writeInFlight(underWriteLock: boolean): boolean {
751
+ if (!this.db!.getAutocommit()) {
752
+ return true;
753
+ }
754
+ return !underWriteLock && this.runningWriteBodies > 0;
755
+ }
756
+
660
757
  /**
661
758
  * {@link readRowsOnce} plus the bounded transient-failure retry
662
759
  * ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
@@ -672,8 +769,8 @@ export class ControlDatabase {
672
769
  * primary-key lookup or a `count(1)`); nothing streams.
673
770
  *
674
771
  * Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
675
- * (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
676
- * and change the right answer.
772
+ * ({@link writeInFlight}) is re-evaluated per attempt — a write can finish between
773
+ * attempts and change the right answer.
677
774
  *
678
775
  * The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
679
776
  * under the write funnel's: the tightest caller deadline over a control read is the
@@ -706,13 +803,20 @@ export class ControlDatabase {
706
803
  label: string,
707
804
  retry = true
708
805
  ): Promise<Record<string, SqlValue>[]> {
806
+ // `retry: false` marks the reads that may run inside a locked write body, which is
807
+ // also what readRowsOnce's committed-read routing must know (see writeInFlight).
808
+ // NOTE: the membership-gate refresh passes it on EVERY trigger, including the
809
+ // unlocked ones (start, reconcile, seed-applied), so those keep the transaction-only
810
+ // routing and can still queue behind a write waiting for the exec mutex (as before this
811
+ // routing existed). Tolerable while its awaiting callers (reconcile, applySeed) carry no
812
+ // tight deadline; if one ever does, pass the locked/unlocked distinction through instead.
709
813
  if (!retry) {
710
- return this.readRowsOnce(sql, params);
814
+ return this.readRowsOnce(sql, params, true);
711
815
  }
712
816
  // The label lands LAST so the call site's name survives the spec-injected pacing —
713
817
  // it changes log attribution only, never behaviour (same layering as lockedWithRetry).
714
818
  return retryControlRead(
715
- () => this.readRowsOnce(sql, params),
819
+ () => this.readRowsOnce(sql, params, false),
716
820
  { ...this.controlReadRetryPacing, label }
717
821
  );
718
822
  }
@@ -844,10 +948,12 @@ export class ControlDatabase {
844
948
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
845
949
  * that seam to model the replicated live-row-plus-tombstone merge state.
846
950
  *
847
- * NOTE: runs a second query (the retired-stamp set) per call, and this sits on the
848
- * membership-gate refresh path; control tables are a handful of rows today, so this
849
- * is cheap — if they ever grow, fold the exclusion into one statement or cache the
850
- * retired-stamp set.
951
+ * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
952
+ * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
953
+ * while `Revocation` has never been written every call consults the cohort about it, and
954
+ * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
955
+ * the exclusion into one statement would not help (it reads the same block), and caching
956
+ * the set would hide a tombstone arriving by replication.
851
957
  *
852
958
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
853
959
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -966,6 +1072,30 @@ export class ControlDatabase {
966
1072
  return this.queryStampId('Strand', strandId);
967
1073
  }
968
1074
 
1075
+ /** `StrandPartyKey` stamp nonce — bound into {@link deleteStrandPartyKey}'s remove digest. */
1076
+ queryStrandPartyKeyStampId(strandId: string): Promise<string | null> {
1077
+ return this.queryStampId('StrandPartyKey', strandId);
1078
+ }
1079
+
1080
+ /**
1081
+ * Read THIS party's own strand membership private key (base64 protobuf; decode with
1082
+ * `strandMemberKeyPair`) for one strand, or null when no `StrandPartyKey` row exists.
1083
+ * The identity source the closed-strand founder bootstrap derives its `Member.Key` /
1084
+ * `Manager.MemberKey` from — deliberately NOT `Strand.MemberPrivateKey`, the
1085
+ * strand-wide read secret every joining party receives.
1086
+ */
1087
+ async queryStrandPartyKey(strandId: string): Promise<string | null> {
1088
+ this.ensureInitialized();
1089
+ for (const row of await this.readRows(
1090
+ 'select PrivateKey from CadreControl.StrandPartyKey where Id = ?',
1091
+ [strandId],
1092
+ 'strand-party-key'
1093
+ )) {
1094
+ return (row.PrivateKey as string | null) ?? null;
1095
+ }
1096
+ return null;
1097
+ }
1098
+
969
1099
  /** `ValidationKey` stamp nonce — bound into {@link deleteValidationKey}'s remove digest. */
970
1100
  queryValidationKeyStampId(key: string): Promise<string | null> {
971
1101
  return this.queryStampId('ValidationKey', key);
@@ -983,9 +1113,18 @@ export class ControlDatabase {
983
1113
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
984
1114
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
985
1115
  *
986
- * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
987
- * gate request while the table only ever grows. Cheap today (a cadre removes peers
988
- * rarely); if removals ever become routine, cache the set and invalidate it on write.
1116
+ * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
1117
+ * `Revocation` has never been written, this node does not hold its block, and Optimystic
1118
+ * consults a block's cohort on every read of a block it does not hold: 1 consult per
1119
+ * call (measured), each a round trip to every other member on a multi-machine party. The
1120
+ * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
1121
+ * at most once per read-repair window (10 s). Do not cache the set instead: a cache
1122
+ * cannot see a tombstone arriving by replication, so it would delay a revocation. The
1123
+ * row count only grows (append-only) but stays small while removals are rare; revisit if
1124
+ * removals become routine and the scan itself shows up in a profile.
1125
+ *
1126
+ * An isolated node that has never received the `Revocation` block reads it as holding no
1127
+ * revocations instead of throwing — see {@link readRevokedStampRows}.
989
1128
  *
990
1129
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
991
1130
  * under the write lock — see that method's note.
@@ -993,24 +1132,77 @@ export class ControlDatabase {
993
1132
  async queryRevokedStamps(tableName: RevocableTable, retry = true): Promise<Set<string>> {
994
1133
  this.ensureInitialized();
995
1134
  const stamps = new Set<string>();
996
- for (const row of await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry)) {
1135
+ for (const row of await this.readRevokedStampRows(tableName, retry)) {
997
1136
  stamps.add(row.StampId as string);
998
1137
  }
999
1138
  return stamps;
1000
1139
  }
1001
1140
 
1141
+ /**
1142
+ * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
1143
+ * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
1144
+ * the block and could ask no other cohort member about it. Every other failure rethrows.
1145
+ * The retry in {@link readRows} runs first, so a node whose connectivity returns within
1146
+ * the read budget still gets the real answer.
1147
+ *
1148
+ * Without this, a machine cut off from every other machine in its party before it ever
1149
+ * received the block — the block does not exist anywhere until the owner files the
1150
+ * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
1151
+ * answer ANY membership, peer-record or device-token lookup, although it holds those
1152
+ * rows locally.
1153
+ *
1154
+ * NOTE: accepted tradeoff — `cohort-unreachable` on THIS read, and only this read, is
1155
+ * treated as "no revocations known". It adds no fail-open that does not already exist:
1156
+ * an isolated node holding a stale `Revocation` block is served it silently (the consult
1157
+ * reaches nobody, so no doubt is raised), and a revocation authored elsewhere is invisible
1158
+ * to it either way until connectivity returns. The other reasons still throw:
1159
+ * `peers-unreachable` means part of the cohort answered, and `claimed-elsewhere` /
1160
+ * `unmaterializable` mean the block exists, so an empty answer could admit a revoked
1161
+ * member. Do not move this into {@link readRows}: an empty answer is known-safe only for
1162
+ * a table whose empty state is its never-written state; decide any other table in its
1163
+ * own reader. Revisit if optimystic starts reporting a held-but-stale block distinctly
1164
+ * (the equivalence above then breaks), or if revocation enforcement must fail closed
1165
+ * under partition.
1166
+ *
1167
+ * NOTE: an isolated node pays the whole read retry (up to
1168
+ * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
1169
+ * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
1170
+ * node's lookups are seen exceeding the admission gate's 2 s deadline
1171
+ * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
1172
+ * fails `cohort-unreachable`.
1173
+ */
1174
+ private async readRevokedStampRows(tableName: RevocableTable, retry: boolean): Promise<Record<string, SqlValue>[]> {
1175
+ try {
1176
+ return await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry);
1177
+ } catch (error) {
1178
+ if (!isCohortUnreachableRead(error)) {
1179
+ throw error;
1180
+ }
1181
+ log('revoked-stamps(%s): no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', tableName, error);
1182
+ return [];
1183
+ }
1184
+ }
1185
+
1002
1186
  /**
1003
1187
  * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
1004
1188
  * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
1005
1189
  * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
1006
- * it. Plain scan with no `where`, so the composite-primary-key point-lookup hazard
1007
- * (see the statement comment in {@link reissueRevocations}) does not arise. Unlocked,
1008
- * like every other read.
1190
+ * it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
1191
+ * the composite-primary-key point-lookup hazard (see the statement comment in
1192
+ * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
1193
+ *
1194
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
1195
+ * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
1196
+ * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
1197
+ * admits no other row under `'Revocation'`.
1009
1198
  */
1010
1199
  async queryRevocations(): Promise<RevocationRow[]> {
1011
1200
  this.ensureInitialized();
1012
1201
  const rows: RevocationRow[] = [];
1013
1202
  for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
1203
+ if (row.TableName === REVOCATION_LEDGER_MARKER.tableName) {
1204
+ continue;
1205
+ }
1014
1206
  rows.push({
1015
1207
  tableName: row.TableName as RevocableTable,
1016
1208
  rowKey: row.RowKey as string,
@@ -1242,6 +1434,15 @@ export class ControlDatabase {
1242
1434
  * The remove digest binds only (Id, StampId) — not Type/MemberPrivateKey — so this
1243
1435
  * works identically for open and closed strands.
1244
1436
  *
1437
+ * When the strand has a `StrandPartyKey` row (this party's own membership identity for
1438
+ * the strand — minted at publish for closed strands), it is deleted IN THE SAME
1439
+ * transaction, with its own `'remove'`-tagged signature and its own `Revocation`
1440
+ * tombstone, so a re-published strand always mints fresh identity and no crash window
1441
+ * can orphan the key row. The party-key delete happens ONLY alongside an actual strand
1442
+ * row delete: a `StrandPartyKey` row with no local `Strand` row is a JOINER's identity
1443
+ * (the joiner never holds the strand row) and is not this method's to destroy — that
1444
+ * is {@link deleteStrandPartyKey}'s.
1445
+ *
1245
1446
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
1246
1447
  * when a row was actually removed.
1247
1448
  */
@@ -1250,7 +1451,141 @@ export class ControlDatabase {
1250
1451
  ownerKey: string,
1251
1452
  signMessage: (message: Uint8Array) => string
1252
1453
  ): Promise<boolean> {
1253
- return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage), {}, 'strand-delete');
1454
+ return this.lockedWithRetry(() => this.deleteStrandAndPartyKey(strandId, ownerKey, signMessage), {}, 'strand-delete');
1455
+ }
1456
+
1457
+ /**
1458
+ * The locked body of {@link deleteStrand}: the `Strand` delete + tombstone, plus — when
1459
+ * one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
1460
+ * transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
1461
+ * per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
1462
+ * digests, mandatory same-transaction tombstones); kept separate rather than
1463
+ * generalizing the shared body because no other guarded table has a companion row.
1464
+ *
1465
+ * NOTE: that duplication is a drift risk, not a correctness one — a future tightening of
1466
+ * {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
1467
+ * will NOT reach this body. Grep for both when you change either; if a second guarded
1468
+ * table ever gains a companion row, generalize instead of copying this a third time.
1469
+ *
1470
+ * Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
1471
+ */
1472
+ private async deleteStrandAndPartyKey(
1473
+ strandId: string,
1474
+ ownerKey: string,
1475
+ signMessage: (message: Uint8Array) => string
1476
+ ): Promise<boolean> {
1477
+ this.ensureInitialized();
1478
+ // retry: false — these reads run inside the locked write body (see queryStampId's NOTE).
1479
+ const strandStamp = await this.queryStampId('Strand', strandId, false);
1480
+ if (strandStamp === null) {
1481
+ log('delete Strand: no row for %s (already absent)', strandId);
1482
+ return false;
1483
+ }
1484
+ const partyKeyStamp = await this.queryStampId('StrandPartyKey', strandId, false);
1485
+
1486
+ const strandSignature = signMessage(
1487
+ buildAuthorizationMessage('CadreControl.Strand', 'remove', [strandId, strandStamp]));
1488
+ const strandRevocationSignature = signMessage(
1489
+ buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['Strand', strandId, strandStamp]));
1490
+ const partyKeySignature = partyKeyStamp === null ? null : signMessage(
1491
+ buildAuthorizationMessage('CadreControl.StrandPartyKey', 'remove', [strandId, partyKeyStamp]));
1492
+ const partyKeyRevocationSignature = partyKeyStamp === null ? null : signMessage(
1493
+ buildAuthorizationMessage('CadreControl.Revocation', 'remove', ['StrandPartyKey', strandId, partyKeyStamp]));
1494
+
1495
+ await this.inTransaction('delete Strand', async () => {
1496
+ await this.db!.exec(`
1497
+ delete from CadreControl.Strand
1498
+ with context OwnerKey = ?, Signature = ?
1499
+ where Id = ?
1500
+ `, [ownerKey, strandSignature, strandId]);
1501
+ await this.db!.exec(`
1502
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1503
+ with context OwnerKey = ?, Signature = ?
1504
+ values ('Strand', ?, ?, 0)
1505
+ `, [ownerKey, strandRevocationSignature, strandId, strandStamp]);
1506
+ if (partyKeyStamp !== null) {
1507
+ await this.db!.exec(`
1508
+ delete from CadreControl.StrandPartyKey
1509
+ with context OwnerKey = ?, Signature = ?
1510
+ where Id = ?
1511
+ `, [ownerKey, partyKeySignature, strandId]);
1512
+ await this.db!.exec(`
1513
+ insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
1514
+ with context OwnerKey = ?, Signature = ?
1515
+ values ('StrandPartyKey', ?, ?, 0)
1516
+ `, [ownerKey, partyKeyRevocationSignature, strandId, partyKeyStamp]);
1517
+ }
1518
+ });
1519
+
1520
+ log('Strand deleted: %s (stamp retired%s)', strandId,
1521
+ partyKeyStamp === null ? '' : '; party key deleted, stamp retired');
1522
+ this.notifyGuardedDelete({ tableName: 'Strand', rowKey: strandId, stampId: strandStamp });
1523
+ if (partyKeyStamp !== null) {
1524
+ this.notifyGuardedDelete({ tableName: 'StrandPartyKey', rowKey: strandId, stampId: partyKeyStamp });
1525
+ }
1526
+ return true;
1527
+ }
1528
+
1529
+ /**
1530
+ * Insert this party's own strand membership identity key (`StrandPartyKey` row) using
1531
+ * an owner signature.
1532
+ *
1533
+ * Mirrors {@link insertStrand}: the owner signs the canonical row-bound authorization
1534
+ * message over (Id, PrivateKey, StampId) — binding the key material means a captured
1535
+ * approval can only ever reproduce the exact key it approved — and the StampId is
1536
+ * persisted as a unique column for single-use anti-replay.
1537
+ *
1538
+ * @param strandId - The strand this key is the party's identity for.
1539
+ * @param privateKey - The party's ed25519 strand member private key, base64 protobuf
1540
+ * (same encoding as `Strand.MemberPrivateKey`; mint with `generateStrandMemberKey`).
1541
+ * @param ownerKey - Public key of the authorizing owner.
1542
+ * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
1543
+ * with the owner's private key, returning a base64url signature.
1544
+ */
1545
+ async insertStrandPartyKey(
1546
+ strandId: string,
1547
+ privateKey: string,
1548
+ ownerKey: string,
1549
+ signMessage: (message: Uint8Array) => string
1550
+ ): Promise<void> {
1551
+ this.ensureInitialized();
1552
+ log('Inserting strand party key for strand: %s', strandId);
1553
+
1554
+ const peerId = this.config.libp2pNode.peerId.toString();
1555
+ const stampId = generateStampId(peerId);
1556
+
1557
+ // Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
1558
+ // Id, PrivateKey, StampId.
1559
+ const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', [strandId, privateKey, stampId]);
1560
+ const signature = signMessage(message);
1561
+
1562
+ await this.execWrite(`
1563
+ insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
1564
+ with context OwnerKey = ?, Signature = ?
1565
+ values (?, ?, ?)
1566
+ `, [ownerKey, signature, strandId, privateKey, stampId], 'strand-party-key-insert');
1567
+
1568
+ log('Strand party key inserted for strand: %s', strandId);
1569
+ }
1570
+
1571
+ /**
1572
+ * Owner-signed removal of one `StrandPartyKey` row — the party's own membership
1573
+ * identity for that strand, stored nowhere else, so this is as destructive as
1574
+ * {@link deleteStrand} on a closed strand. {@link deleteStrand} already removes the
1575
+ * founder's row alongside the `Strand` row in one transaction; this standalone form
1576
+ * exists for a party-key row with NO local `Strand` row (a joiner's identity — the
1577
+ * shape the formation tickets build on).
1578
+ *
1579
+ * Mirrors {@link deleteValidationKey}: `'remove'`-tagged digest over (Id, StampId),
1580
+ * `Revocation` tombstone in the same transaction. A no-op (no throw, no tombstone)
1581
+ * when the row does not exist — `false` then, `true` when a row was actually removed.
1582
+ */
1583
+ deleteStrandPartyKey(
1584
+ strandId: string,
1585
+ ownerKey: string,
1586
+ signMessage: (message: Uint8Array) => string
1587
+ ): Promise<boolean> {
1588
+ return this.lockedWithRetry(() => this.deleteGuardedRow('StrandPartyKey', strandId, ownerKey, signMessage), {}, 'strand-party-key-delete');
1254
1589
  }
1255
1590
 
1256
1591
  /**
@@ -1588,16 +1923,27 @@ export class ControlDatabase {
1588
1923
  });
1589
1924
 
1590
1925
  log('%s deleted: %s (stamp retired)', table, keyValue);
1591
- if (this.guardedDeleteListener) {
1592
- try {
1593
- this.guardedDeleteListener({ tableName: table, rowKey: keyValue, stampId });
1594
- } catch (error) {
1595
- log('guarded-delete listener threw (committed delete unaffected): %o', error);
1596
- }
1597
- }
1926
+ this.notifyGuardedDelete({ tableName: table, rowKey: keyValue, stampId });
1598
1927
  return true;
1599
1928
  }
1600
1929
 
1930
+ /**
1931
+ * Fire the guarded-delete listener for one committed tombstone, swallowing (and
1932
+ * logging) a listener throw — a committed delete never fails because bookkeeping did.
1933
+ * Shared by {@link deleteGuardedRow} and {@link deleteStrandAndPartyKey} (which files
1934
+ * up to two tombstones per transaction).
1935
+ */
1936
+ private notifyGuardedDelete(revocation: RevokedRowRef): void {
1937
+ if (!this.guardedDeleteListener) {
1938
+ return;
1939
+ }
1940
+ try {
1941
+ this.guardedDeleteListener(revocation);
1942
+ } catch (error) {
1943
+ log('guarded-delete listener threw (committed delete unaffected): %o', error);
1944
+ }
1945
+ }
1946
+
1601
1947
  /**
1602
1948
  * Delete one guarded row that an ALREADY-COMMITTED `Revocation` tombstone retires —
1603
1949
  * the local catch-up for a node that converged on a removal's tombstone while still
@@ -1706,8 +2052,9 @@ export class ControlDatabase {
1706
2052
  *
1707
2053
  * Two rows are deliberately left alone:
1708
2054
  *
1709
- * - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `OwnerKey`) — no reap branch
1710
- * exists on their `AuthorizedDelete`, so the delete would throw rather than no-op.
2055
+ * - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `StrandPartyKey`, `OwnerKey`)
2056
+ * — no reap branch exists on their `AuthorizedDelete`, so the delete would throw
2057
+ * rather than no-op.
1711
2058
  * - **This node's OWN `CadrePeer` / `DeviceToken` row** (`selfPeerId`). Reaping it would
1712
2059
  * fight this node's own re-registration: `registerSelf` / `retouchSelfDeviceToken` run
1713
2060
  * on the heartbeat and the growth drain, and after a self-reap their insert-if-absent
@@ -1727,8 +2074,11 @@ export class ControlDatabase {
1727
2074
  * skip-self rule is exercisable without one.
1728
2075
  *
1729
2076
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
1730
- * one {@link queryStampId} point lookup per tombstone, and the empty-table early return
1731
- * makes the common case (a party that has never revoked anyone) a single scan per pass.
2077
+ * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
2078
+ * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
2079
+ * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
2080
+ * only while `Revocation` has never been written; the ledger marker
2081
+ * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
1732
2082
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
1733
2083
  * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
1734
2084
  * party; if the table ever grows, persist a node-local high-water mark of what has
@@ -1845,6 +2195,85 @@ export class ControlDatabase {
1845
2195
  }, {}, 'revocation-reissue');
1846
2196
  }
1847
2197
 
2198
+ /**
2199
+ * Owner-signed filing of the singleton `Revocation` ledger marker
2200
+ * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
2201
+ *
2202
+ * Why it exists: while `Revocation` has never been written, this node does not hold its
2203
+ * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
2204
+ * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
2205
+ * reads this table. Once any row exists the block is held and re-checked at most once per
2206
+ * read-repair window, like every other populated table. The schema's table comment says
2207
+ * why the row can never read as a retirement.
2208
+ *
2209
+ * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
2210
+ * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
2211
+ * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
2212
+ * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
2213
+ * full composite primary key, which is served as a point lookup that can miss an existing
2214
+ * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
2215
+ *
2216
+ * Two owners filing at once: the loser either sees the marker in its guard or is refused
2217
+ * on the primary key, and both answer `'already-open'`.
2218
+ *
2219
+ * The caller decides WHEN. A marker committed while the node is alone is local-only and
2220
+ * can fork the collection, so {@link CadreNode} files it only while connected; this
2221
+ * method does not look at connectivity.
2222
+ *
2223
+ * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
2224
+ * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
2225
+ * private key, returning a base64url signature.
2226
+ * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
2227
+ * already there.
2228
+ */
2229
+ async openRevocationLedger(
2230
+ ownerKey: string,
2231
+ signMessage: (message: Uint8Array) => string
2232
+ ): Promise<RevocationLedgerOpenResult> {
2233
+ this.ensureInitialized();
2234
+ const { tableName, rowKey, stampId } = REVOCATION_LEDGER_MARKER;
2235
+ const signature = signMessage(buildAuthorizationMessage(
2236
+ 'CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]
2237
+ ));
2238
+ try {
2239
+ return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
2240
+ if (await this.revocationLedgerFiled()) {
2241
+ return 'already-open';
2242
+ }
2243
+ // Bare `exec`: already inside the write lock, which is NOT re-entrant.
2244
+ await this.db!.exec(`
2245
+ insert into CadreControl.Revocation (TableName, RowKey, StampId)
2246
+ with context OwnerKey = ?, Signature = ?
2247
+ values (?, ?, ?)
2248
+ `, [ownerKey, signature, tableName, rowKey, stampId]);
2249
+ log('Revocation ledger marker filed');
2250
+ return 'opened';
2251
+ }, {}, 'revocation-ledger-open');
2252
+ } catch (error) {
2253
+ if (!isRevocationLedgerConflict(error)) {
2254
+ throw error;
2255
+ }
2256
+ log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
2257
+ return 'already-open';
2258
+ }
2259
+ }
2260
+
2261
+ /**
2262
+ * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
2263
+ * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
2264
+ * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
2265
+ */
2266
+ private async revocationLedgerFiled(): Promise<boolean> {
2267
+ const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
2268
+ const rows = await this.readRows(
2269
+ 'select StampId from CadreControl.Revocation where TableName = ?',
2270
+ [tableName],
2271
+ 'revocation-ledger',
2272
+ false
2273
+ );
2274
+ return rows.some(row => row.StampId === stampId);
2275
+ }
2276
+
1848
2277
  /**
1849
2278
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
1850
2279
  *
@@ -1906,6 +2335,20 @@ export class ControlDatabase {
1906
2335
  this.guardedDeleteListener = listener;
1907
2336
  }
1908
2337
 
2338
+ /**
2339
+ * Wire (or clear, with null) the single listener notified when {@link lockedWithRetry}
2340
+ * ABANDONS a control write. Same ownership contract as
2341
+ * {@link setMembershipChangeListener}: one CadreNode, wired in `start()` and cleared on
2342
+ * teardown, and a second call replaces the first rather than fanning out.
2343
+ *
2344
+ * Covers every write that reaches the funnel, foreground and background alike. A
2345
+ * foreground caller also sees the rethrown error; a background one often does not, which
2346
+ * is the whole reason this exists.
2347
+ */
2348
+ setControlWriteAbandonedListener(listener: ControlWriteAbandonedListener | null): void {
2349
+ this.controlWriteAbandonedListener = listener;
2350
+ }
2351
+
1909
2352
  /**
1910
2353
  * Run a `CadrePeer` row mutation and notify the membership listener once it has
1911
2354
  * COMMITTED.
@@ -1991,11 +2434,27 @@ export class ControlDatabase {
1991
2434
  async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
1992
2435
  // Chain behind the current tail regardless of how it settled, then park a
1993
2436
  // swallowed copy as the new tail so one failed write never poisons the queue.
1994
- const run = this.writeQueue.then(fn, fn);
2437
+ const body = () => this.runWriteBody(fn);
2438
+ const run = this.writeQueue.then(body, body);
1995
2439
  this.writeQueue = run.then(() => undefined, () => undefined);
1996
2440
  return run;
1997
2441
  }
1998
2442
 
2443
+ /**
2444
+ * Run one locked body, counted in {@link runningWriteBodies} from the moment it takes
2445
+ * the lock until it settles — so an unlocked read routes around it even while its
2446
+ * first statement is still waiting for the database's exec mutex, before
2447
+ * `getAutocommit()` can show it (see {@link readRowsOnce}).
2448
+ */
2449
+ private async runWriteBody<T>(fn: () => Promise<T>): Promise<T> {
2450
+ this.runningWriteBodies++;
2451
+ try {
2452
+ return await fn();
2453
+ } finally {
2454
+ this.runningWriteBodies--;
2455
+ }
2456
+ }
2457
+
1999
2458
  /**
2000
2459
  * {@link withWriteLock} plus the bounded transient-failure retry
2001
2460
  * ({@link retryControlWrite}) — the wrapper the whole public write surface goes
@@ -2019,14 +2478,26 @@ export class ControlDatabase {
2019
2478
  * `label` names the operation in this loop's debug lines and nothing else
2020
2479
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
2021
2480
  * several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
2022
- * attributed to a write — keep new call sites labelled.
2481
+ * attributed to a write — keep new call sites labelled. It is also the only thing that
2482
+ * attributes an abandonment reported through {@link setControlWriteAbandonedListener}.
2483
+ *
2484
+ * The abandonment observer lands LAST, after both the policy and the spec-injected
2485
+ * pacing, so this class's single listener is the one seam for it — a caller that
2486
+ * smuggled its own in through `policy` would be displaced here rather than fanning out.
2023
2487
  */
2024
2488
  private lockedWithRetry<T>(fn: () => Promise<T>, policy: ControlWriteRetryOptions = {}, label?: string): Promise<T> {
2025
2489
  // The label lands LAST so a call site's own name survives both the policy and the
2026
2490
  // spec-injected pacing; it changes log attribution only, never behaviour.
2027
2491
  return retryControlWrite(
2028
2492
  () => this.withWriteLock(fn),
2029
- { ...policy, ...this.controlWriteRetryPacing, ...(label !== undefined ? { label } : {}) }
2493
+ {
2494
+ ...policy,
2495
+ ...this.controlWriteRetryPacing,
2496
+ ...(label !== undefined ? { label } : {}),
2497
+ // Read through the field, not captured: a listener wired (or cleared) while a write
2498
+ // is already in backoff still governs that write's abandonment.
2499
+ onAbandon: (abandonment) => this.controlWriteAbandonedListener?.(abandonment)
2500
+ }
2030
2501
  );
2031
2502
  }
2032
2503
 
@@ -2503,22 +2974,19 @@ export class ControlDatabase {
2503
2974
  /**
2504
2975
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
2505
2976
  *
2506
- * This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
2507
- * the invitation never paid for. It is served by a table SCAN, deliberately: a bare
2508
- * equality on a non-key column is a shape the optimystic vtab declines to claim, so the
2509
- * engine applies the filter itself over a full read of the table.
2510
- *
2511
- * NOTE: it was briefly an index seek, and that is what the scan is protecting against.
2512
- * `formation-unique-token-redesign` took `Token` out of the primary key and declared
2513
- * `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
2514
- * under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
2515
- * not produce". The tripwire fired. Across machines a secondary-index descent returns only
2516
- * the rows the READING machine wrote, so this count under-reported without bound on a
2517
- * multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
2518
- * measurement is recorded in the block comment at the `FormationUsage` declaration in
2519
- * `schemas/control.qsql`, and the engine defect in
2520
- * `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
2521
- * speed this up while that ticket is open.
2977
+ * Served by a seek through the `FormationUsageByToken` index rather than a full scan of
2978
+ * the table, which is append-only and grows for the life of the party.
2979
+ *
2980
+ * NOTE: this count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a
2981
+ * seat the invitation never paid for — and reading it through a secondary index makes the
2982
+ * cap depend on that index converging across machines. It did not, from 2026-08-04 to
2983
+ * 2026-08-25: a descent on a second machine returned only the rows that machine had
2984
+ * written, and the index was removed until the engine was fixed upstream (re-measured
2985
+ * 2026-09-17, `complete/restore-formation-usage-token-index`). The live guard is the
2986
+ * integration-tests scenario `strand-formation-concurrent-redemption`, which asserts both
2987
+ * machines' views of a raced redemption. If it fails on BOTH views again, index
2988
+ * convergence has regressed — fix the engine or take this read off the index, and do not
2989
+ * weaken that scenario's assertions to get a green run.
2522
2990
  *
2523
2991
  * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
2524
2992
  * locked write body — same per-call opt-out, and for the same reason, as