@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  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 +883 -102
  7. package/dist/cadre-node.js +1800 -209
  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 +318 -44
  16. package/dist/control-database.js +516 -74
  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 +133 -21
  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 +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -33,32 +33,6 @@ export declare const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15000;
33
33
  * count against this cap.
34
34
  */
35
35
  export declare const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
36
- /**
37
- * Default per-sibling budget for one proactive control-cohort dial, in ms.
38
- *
39
- * `libp2p.dial(addrs)` is handed EVERY address a sibling resolved to, and each
40
- * one carries its own attempt timeout (js-libp2p's default is ~10 s, and
41
- * `@optimystic/db-p2p` does not override it). Some addresses cost more than one
42
- * attempt on their own: a `…/p2p-circuit/webrtc/…` address makes
43
- * `@libp2p/webrtc` dial the signalling leg to the relay hop FIRST and only then
44
- * the peer, so a two-address WebRTC sibling can block the sequential dial loop
45
- * for four attempt timeouts. Against unroutable addresses none of those attempts
46
- * ever answers, so an offline sibling's cost is address fan-out × relay hops ×
47
- * ~10 s — a number nothing in this package chose or bounds.
48
- *
49
- * This budget makes the SIBLING the unit instead of the address: one pass costs
50
- * at most (dialed siblings) × this, whatever each sibling's addresses look like.
51
- * Deliberately wider than one attempt timeout so a reachable sibling whose first
52
- * address is stale still gets a genuine try at its later ones; a sibling that
53
- * needs longer than this is simply retried next pass (every
54
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}), which is what the dial loop's
55
- * best-effort contract already does with any other dial failure.
56
- *
57
- * Override per node with `network.controlCohort.dialTimeoutMs` — tests that
58
- * drive dead addresses on purpose set it low so a pass's duration is a chosen
59
- * number rather than a transitive libp2p default stretched by machine load.
60
- */
61
- export declare const DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS = 20000;
62
36
  /** Outcome of {@link selectControlCohortDials}: who to dial + what the cap dropped. */
63
37
  export interface ControlCohortSelection {
64
38
  /**
@@ -71,6 +45,19 @@ export interface ControlCohortSelection {
71
45
  /** Non-owner members dropped by the `targetDegree` cap (0 when none). */
72
46
  cappedNonOwner: number;
73
47
  }
48
+ /** What one {@link CadreNode.reconcileControlCohort} pass did. */
49
+ export interface ControlCohortReconcileResult {
50
+ /**
51
+ * Peer ids this pass dialled and whose dial resolved, in dial order: selected
52
+ * siblings on the steady-state pass, retained bootstrap peers on the cold-start
53
+ * pass (the pass takes one branch or the other, never both). A peer already
54
+ * connected when the pass began is never listed — the pass skips it. A resolved
55
+ * dial can still be refused moments later by the remote's membership gate, so
56
+ * an entry means "this pass opened a connection", not "that connection is still
57
+ * open".
58
+ */
59
+ dialed: string[];
60
+ }
74
61
  /**
75
62
  * Backbone-preferential, bounded-out-degree selection of control-cohort siblings
76
63
  * to dial.
@@ -33,32 +33,6 @@ export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15000;
33
33
  * count against this cap.
34
34
  */
35
35
  export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
36
- /**
37
- * Default per-sibling budget for one proactive control-cohort dial, in ms.
38
- *
39
- * `libp2p.dial(addrs)` is handed EVERY address a sibling resolved to, and each
40
- * one carries its own attempt timeout (js-libp2p's default is ~10 s, and
41
- * `@optimystic/db-p2p` does not override it). Some addresses cost more than one
42
- * attempt on their own: a `…/p2p-circuit/webrtc/…` address makes
43
- * `@libp2p/webrtc` dial the signalling leg to the relay hop FIRST and only then
44
- * the peer, so a two-address WebRTC sibling can block the sequential dial loop
45
- * for four attempt timeouts. Against unroutable addresses none of those attempts
46
- * ever answers, so an offline sibling's cost is address fan-out × relay hops ×
47
- * ~10 s — a number nothing in this package chose or bounds.
48
- *
49
- * This budget makes the SIBLING the unit instead of the address: one pass costs
50
- * at most (dialed siblings) × this, whatever each sibling's addresses look like.
51
- * Deliberately wider than one attempt timeout so a reachable sibling whose first
52
- * address is stale still gets a genuine try at its later ones; a sibling that
53
- * needs longer than this is simply retried next pass (every
54
- * {@link DEFAULT_CONTROL_COHORT_RECONCILE_MS}), which is what the dial loop's
55
- * best-effort contract already does with any other dial failure.
56
- *
57
- * Override per node with `network.controlCohort.dialTimeoutMs` — tests that
58
- * drive dead addresses on purpose set it low so a pass's duration is a chosen
59
- * number rather than a transitive libp2p default stretched by machine load.
60
- */
61
- export const DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS = 20000;
62
36
  /** Stable peerId ordering so the bounded fill is identical across passes/nodes. */
63
37
  function byPeerId(a, b) {
64
38
  if (a.peerId < b.peerId)
@@ -1 +1 @@
1
- {"version":3,"file":"control-cohort.js","sourceRoot":"","sources":["../src/control-cohort.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,6BAA6B,EAAE,MAAM,qBAAqB,CAAC;AAGpE;;;;GAIG;AACH,MAAM,CAAC,MAAM,mCAAmC,GAAG,KAAM,CAAC;AAE1D;;;;GAIG;AACH,MAAM,CAAC,MAAM,oCAAoC,GAAG,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,sCAAsC,GAAG,KAAM,CAAC;AAe7D,mFAAmF;AACnF,SAAS,QAAQ,CAAC,CAAgB,EAAE,CAAgB;IAClD,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC,CAAC;IACnC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC;IAClC,OAAO,CAAC,CAAC;AACX,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,wBAAwB,CACtC,QAAyB,EACzB,SAA8B,EAC9B,YAAoB;IAEpB,MAAM,QAAQ,GAAoB,EAAE,CAAC;IACrC,MAAM,IAAI,GAAoB,EAAE,CAAC;IAEjC,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,MAAM,MAAM,GAAG,6BAA6B,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC1D,IAAI,MAAM,KAAK,IAAI,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC7C,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtB,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IAED,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACxB,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAEpB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAEhC,OAAO;QACL,KAAK,EAAE,CAAC,GAAG,QAAQ,EAAE,GAAG,IAAI,CAAC;QAC7B,cAAc,EAAE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM;KAC1C,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"control-cohort.js","sourceRoot":"","sources":["../src/control-cohort.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,6BAA6B,EAAE,MAAM,qBAAqB,CAAC;AAGpE;;;;GAIG;AACH,MAAM,CAAC,MAAM,mCAAmC,GAAG,KAAM,CAAC;AAE1D;;;;GAIG;AACH,MAAM,CAAC,MAAM,oCAAoC,GAAG,CAAC,CAAC;AAkCtD,mFAAmF;AACnF,SAAS,QAAQ,CAAC,CAAgB,EAAE,CAAgB;IAClD,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC,CAAC;IACnC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC;IAClC,OAAO,CAAC,CAAC;AACX,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,wBAAwB,CACtC,QAAyB,EACzB,SAA8B,EAC9B,YAAoB;IAEpB,MAAM,QAAQ,GAAoB,EAAE,CAAC;IACrC,MAAM,IAAI,GAAoB,EAAE,CAAC;IAEjC,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;QAC5B,MAAM,MAAM,GAAG,6BAA6B,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC1D,IAAI,MAAM,KAAK,IAAI,IAAI,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAC7C,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtB,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IAED,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACxB,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAEpB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAEhC,OAAO;QACL,KAAK,EAAE,CAAC,GAAG,QAAQ,EAAE,GAAG,IAAI,CAAC;QAC7B,cAAc,EAAE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM;KAC1C,CAAC;AACJ,CAAC"}
@@ -2,8 +2,9 @@ import { Database } from '@quereus/quereus';
2
2
  import type { SqlParameters } from '@quereus/quereus';
3
3
  import type { Libp2p } from '@libp2p/interface';
4
4
  import type { IRepo } from '@optimystic/db-core';
5
- import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow } from './types.js';
5
+ import type { StrandRow, PeerAddressRecord, CadrePeerRow, RevocationRow, RevocationLedgerOpenResult, DeviceTokenRecord, DeviceTokenRow } from './types.js';
6
6
  import type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
7
+ import type { ControlRetryAbandonment } from './control-retry.js';
7
8
  export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
8
9
  /**
9
10
  * Generate a unique stamp ID for transaction authorization.
@@ -154,14 +155,40 @@ export declare function formationConsentMessage(fields: {
154
155
  peerKey: string;
155
156
  disclosure: string;
156
157
  }): Uint8Array;
158
+ /**
159
+ * Did this write fail because the strand id is already seated?
160
+ *
161
+ * {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
162
+ * landed" (re-read, and no-op when the row matches) apart from every other rejection —
163
+ * an unauthorized signer, a retired stamp — which must keep surfacing.
164
+ *
165
+ * Matched by TEXT, not by type: the typed engine error does not survive the trip out of
166
+ * optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
167
+ * Fails CLOSED — a rewording upstream turns the idempotent branch back into the raw
168
+ * uniqueness error the caller saw before, never into a silent overwrite. The
169
+ * `publish-strand.spec.ts` repeat-publish cases assert against the live engine error, so a
170
+ * reword reddens there.
171
+ */
172
+ export declare function isStrandIdConflict(error: unknown): boolean;
173
+ /**
174
+ * The one `CadreControl.Revocation` row that retires nothing, filed once by an owner
175
+ * ({@link ControlDatabase.openRevocationLedger}) so the table is never a never-written
176
+ * block. The schema's table comment says why that matters and why the row can never read
177
+ * as a retirement; `RowIsGone` pins this exact triple.
178
+ */
179
+ export declare const REVOCATION_LEDGER_MARKER: {
180
+ readonly tableName: "Revocation";
181
+ readonly rowKey: "ledger";
182
+ readonly stampId: "opened";
183
+ };
157
184
  /**
158
185
  * Guarded tables a node may reap locally once their tombstone has committed — the
159
186
  * tables whose `AuthorizedDelete` carries the REAP branch (see the constraint comment on
160
- * `CadrePeer.AuthorizedDelete` in the schema). `Strand` is deliberately absent: its row
161
- * carries `MemberPrivateKey`, the party's own membership secret for that network, stored
162
- * nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any future change).
163
- * `OwnerKey` has no production removal path and `MinOneOwner` makes an automated
164
- * owner-key reap a party-bricking hazard.
187
+ * `CadrePeer.AuthorizedDelete` in the schema). `Strand` and `StrandPartyKey` are
188
+ * deliberately absent: their rows carry `MemberPrivateKey` / `PrivateKey` — party
189
+ * secrets stored nowhere else (tickets/backlog/debt-strand-tombstone-reap.md owns any
190
+ * future change). `OwnerKey` has no production removal path and `MinOneOwner` makes an
191
+ * automated owner-key reap a party-bricking hazard.
165
192
  */
166
193
  export declare const REAPABLE_TABLES: readonly ["CadrePeer", "DeviceToken", "ValidationKey"];
167
194
  export type ReapableTable = (typeof REAPABLE_TABLES)[number];
@@ -193,6 +220,19 @@ export type RevokedRowRef = Omit<RevocationRow, 'reissuedAt'>;
193
220
  * swallows and logs anyway — a committed delete never fails because bookkeeping did).
194
221
  */
195
222
  export type GuardedDeleteListener = (revocation: RevokedRowRef) => void;
223
+ /**
224
+ * Notified when the control-write retry funnel GIVES UP on a local control write — the
225
+ * classifier declined the failure as non-transient, or every attempt / the elapsed budget
226
+ * ran out. Carries the operation label, how far it got and the error
227
+ * ({@link ControlRetryAbandonment}).
228
+ *
229
+ * The seam that stops an abandoned BACKGROUND write from disappearing: those are `void`-ed
230
+ * with a `debug`-only catch, and that namespace is off unless somebody set `DEBUG=`, so
231
+ * without this the write, the operator and the embedding app all learn nothing. Synchronous
232
+ * and must not throw — the retry loop swallows and logs if it does, because the write's own
233
+ * failure is what has to reach the caller.
234
+ */
235
+ export type ControlWriteAbandonedListener = (abandonment: ControlRetryAbandonment) => void;
196
236
  export interface ControlDatabaseConfig {
197
237
  /** Party ID for the control network */
198
238
  partyId: string;
@@ -218,8 +258,16 @@ export declare class ControlDatabase {
218
258
  private initialized;
219
259
  private membershipListener;
220
260
  private guardedDeleteListener;
261
+ private controlWriteAbandonedListener;
221
262
  /** Tail of the local-write chain — see {@link withWriteLock}. */
222
263
  private writeQueue;
264
+ /**
265
+ * Locked write bodies currently running (see {@link runWriteBody}); what
266
+ * {@link readRowsOnce} consults to route an unlocked read around a write that has
267
+ * started but not yet opened its transaction. At most 1 while every writer takes the
268
+ * lock — a count rather than a flag so it stays truthful if that ever stops holding.
269
+ */
270
+ private runningWriteBodies;
223
271
  /**
224
272
  * Pacing seams for {@link lockedWithRetry}. Production leaves this empty (real backoff,
225
273
  * real clock); specs inject a recorded `sleep` / fake `now` so no test waits out a real
@@ -274,11 +322,33 @@ export declare class ControlDatabase {
274
322
  * opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
275
323
  * publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
276
324
  *
277
- * `getAutocommit()` reports the whole `Database` rather than this call (the same
278
- * property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
279
- * false means some writer's transaction is open, which is the only situation where a
280
- * read would have blocked. The check races that writer harmlessly in both directions
281
- * — losing it either reads normally (today's behaviour) or reads committed state.
325
+ * "A write is in flight" is {@link writeInFlight}, and it needs TWO signals because a
326
+ * write is invisible to `getAutocommit()` for part of its life. `Database.exec` takes
327
+ * the exec mutex first; the implicit transaction — the moment `getAutocommit()` turns
328
+ * false — opens only after the write has acquired the mutex, planned the statement,
329
+ * and awaited `begin()` on every connection. While the write WAITS for the mutex (a
330
+ * serialized read holds it, say) it still reports autocommit, so a read routed on
331
+ * `getAutocommit()` alone queued behind the write and answered only after its whole
332
+ * commit — tens of seconds against a slow cohort member, which is what timed out the
333
+ * degraded-cohort scenario's `isMember` read (verified against the engine directly;
334
+ * `complete/control-read-queues-behind-a-write-waiting-for-the-database`). So an
335
+ * UNLOCKED read also takes the committed path while any locked write body is running
336
+ * ({@link runningWriteBodies}), which covers that queued window and everything else a
337
+ * body does before its transaction opens. A read issued INSIDE a locked body
338
+ * (`underWriteLock`) must not: that body is the one being counted, and its guard reads
339
+ * need the refreshing path — so it keeps the transaction-only test.
340
+ *
341
+ * Both signals are sampled synchronously just before `eval`, which claims its place
342
+ * on the mutex in that same tick, so the only race left is a body starting or ending
343
+ * in between: a body that starts after the sample queues its statements BEHIND this
344
+ * read, and one that ended before it has nothing left to queue behind.
345
+ *
346
+ * Residual gaps, both of which still queue a read behind a write:
347
+ * - an EXPLICIT transaction ({@link inTransaction}) disqualifies the committed path in
348
+ * Quereus itself, so a read overlapping such a body's `COMMIT` falls back to the
349
+ * serialized path and waits for it;
350
+ * - a writer that bypasses {@link withWriteLock} is not counted (in this repo only
351
+ * `reference-app-web`'s deliberately-rejected diagnostics insert does).
282
352
  *
283
353
  * What a committed read gives up is observing a write that has not finished
284
354
  * committing, and that is safe for every caller here: an AWAITED write is committed by
@@ -286,8 +356,24 @@ export declare class ControlDatabase {
286
356
  * open explicit transaction and stays serialized, so it still sees its own
287
357
  * transaction's rows. A read after an UNAWAITED write on this database would be wrong
288
358
  * — no such caller exists, and {@link withWriteLock} is what keeps it that way.
359
+ *
360
+ * NOTE: counting whole bodies widens the stale (non-refreshing) window from "a
361
+ * transaction is open" to "a locked body is running", which adds a body's pre-transaction
362
+ * work and its post-commit tail (a `CadrePeer` write's membership-listener read). The
363
+ * slow part of a write against a degraded cohort is its commit, already inside the old
364
+ * window, so the added time is short; if a poller for replicated rows ever starves
365
+ * behind back-to-back local writes, narrow the count to the span from a body's first
366
+ * statement to its transaction opening.
289
367
  */
290
368
  private readRowsOnce;
369
+ /**
370
+ * Whether a control read should ask for a committed read — see {@link readRowsOnce}.
371
+ * `getAutocommit()` reports the whole `Database` (the property
372
+ * {@link assertCommitBoundary} leans on), so false means some writer's transaction is
373
+ * open. An unlocked read also counts a running locked body that has not opened its
374
+ * transaction yet; a read inside such a body does not, since it is that body.
375
+ */
376
+ private writeInFlight;
291
377
  /**
292
378
  * {@link readRowsOnce} plus the bounded transient-failure retry
293
379
  * ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
@@ -303,8 +389,8 @@ export declare class ControlDatabase {
303
389
  * primary-key lookup or a `count(1)`); nothing streams.
304
390
  *
305
391
  * Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
306
- * (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
307
- * and change the right answer.
392
+ * ({@link writeInFlight}) is re-evaluated per attempt — a write can finish between
393
+ * attempts and change the right answer.
308
394
  *
309
395
  * The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
310
396
  * under the write funnel's: the tightest caller deadline over a control read is the
@@ -391,10 +477,12 @@ export declare class ControlDatabase {
391
477
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
392
478
  * that seam to model the replicated live-row-plus-tombstone merge state.
393
479
  *
394
- * NOTE: runs a second query (the retired-stamp set) per call, and this sits on the
395
- * membership-gate refresh path; control tables are a handful of rows today, so this
396
- * is cheap — if they ever grow, fold the exclusion into one statement or cache the
397
- * retired-stamp set.
480
+ * NOTE: runs a second query (the retired-stamp set) per call, on the membership-gate
481
+ * refresh path. Its cost is storage consults, not rows (see {@link queryRevokedStamps}):
482
+ * while `Revocation` has never been written every call consults the cohort about it, and
483
+ * the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
484
+ * the exclusion into one statement would not help (it reads the same block), and caching
485
+ * the set would hide a tombstone arriving by replication.
398
486
  *
399
487
  * NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
400
488
  * (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
@@ -465,6 +553,16 @@ export declare class ControlDatabase {
465
553
  queryDeviceTokenStampId(peerId: string): Promise<string | null>;
466
554
  /** `Strand` stamp nonce — bound into {@link deleteStrand}'s remove digest. */
467
555
  queryStrandStampId(strandId: string): Promise<string | null>;
556
+ /** `StrandPartyKey` stamp nonce — bound into {@link deleteStrandPartyKey}'s remove digest. */
557
+ queryStrandPartyKeyStampId(strandId: string): Promise<string | null>;
558
+ /**
559
+ * Read THIS party's own strand membership private key (base64 protobuf; decode with
560
+ * `strandMemberKeyPair`) for one strand, or null when no `StrandPartyKey` row exists.
561
+ * The identity source the closed-strand founder bootstrap derives its `Member.Key` /
562
+ * `Manager.MemberKey` from — deliberately NOT `Strand.MemberPrivateKey`, the
563
+ * strand-wide read secret every joining party receives.
564
+ */
565
+ queryStrandPartyKey(strandId: string): Promise<string | null>;
468
566
  /** `ValidationKey` stamp nonce — bound into {@link deleteValidationKey}'s remove digest. */
469
567
  queryValidationKeyStampId(key: string): Promise<string | null>;
470
568
  /**
@@ -479,21 +577,69 @@ export declare class ControlDatabase {
479
577
  * ({@link queryCadrePeers}, {@link queryPeerRecord},
480
578
  * {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
481
579
  *
482
- * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
483
- * gate request while the table only ever grows. Cheap today (a cadre removes peers
484
- * rarely); if removals ever become routine, cache the set and invalidate it on write.
580
+ * NOTE: the per-call cost that matters is the storage layer's, not the row count. While
581
+ * `Revocation` has never been written, this node does not hold its block, and Optimystic
582
+ * consults a block's cohort on every read of a block it does not hold: 1 consult per
583
+ * call (measured), each a round trip to every other member on a multi-machine party. The
584
+ * ledger marker ({@link openRevocationLedger}) ends that — a held block is re-consulted
585
+ * at most once per read-repair window (10 s). Do not cache the set instead: a cache
586
+ * cannot see a tombstone arriving by replication, so it would delay a revocation. The
587
+ * row count only grows (append-only) but stays small while removals are rare; revisit if
588
+ * removals become routine and the scan itself shows up in a profile.
589
+ *
590
+ * An isolated node that has never received the `Revocation` block reads it as holding no
591
+ * revocations instead of throwing — see {@link readRevokedStampRows}.
485
592
  *
486
593
  * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
487
594
  * under the write lock — see that method's note.
488
595
  */
489
596
  queryRevokedStamps(tableName: RevocableTable, retry?: boolean): Promise<Set<string>>;
597
+ /**
598
+ * The `Revocation` scan behind {@link queryRevokedStamps}, answering NO ROWS when the read
599
+ * fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
600
+ * the block and could ask no other cohort member about it. Every other failure rethrows.
601
+ * The retry in {@link readRows} runs first, so a node whose connectivity returns within
602
+ * the read budget still gets the real answer.
603
+ *
604
+ * Without this, a machine cut off from every other machine in its party before it ever
605
+ * received the block — the block does not exist anywhere until the owner files the
606
+ * ledger marker ({@link openRevocationLedger}) on a connected reconcile pass — cannot
607
+ * answer ANY membership, peer-record or device-token lookup, although it holds those
608
+ * rows locally.
609
+ *
610
+ * NOTE: accepted tradeoff — `cohort-unreachable` on THIS read, and only this read, is
611
+ * treated as "no revocations known". It adds no fail-open that does not already exist:
612
+ * an isolated node holding a stale `Revocation` block is served it silently (the consult
613
+ * reaches nobody, so no doubt is raised), and a revocation authored elsewhere is invisible
614
+ * to it either way until connectivity returns. The other reasons still throw:
615
+ * `peers-unreachable` means part of the cohort answered, and `claimed-elsewhere` /
616
+ * `unmaterializable` mean the block exists, so an empty answer could admit a revoked
617
+ * member. Do not move this into {@link readRows}: an empty answer is known-safe only for
618
+ * a table whose empty state is its never-written state; decide any other table in its
619
+ * own reader. Revisit if optimystic starts reporting a held-but-stale block distinctly
620
+ * (the equivalence above then breaks), or if revocation enforcement must fail closed
621
+ * under partition.
622
+ *
623
+ * NOTE: an isolated node pays the whole read retry (up to
624
+ * `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
625
+ * answers, and {@link queryPeerRecord} then issues its row scan on top. If an isolated
626
+ * node's lookups are seen exceeding the admission gate's 2 s deadline
627
+ * (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this read when the first attempt
628
+ * fails `cohort-unreachable`.
629
+ */
630
+ private readRevokedStampRows;
490
631
  /**
491
632
  * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
492
633
  * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
493
634
  * enumerates what this node holds before {@link reissueRevocations} re-broadcasts
494
- * it. Plain scan with no `where`, so the composite-primary-key point-lookup hazard
495
- * (see the statement comment in {@link reissueRevocations}) does not arise. Unlocked,
496
- * like every other read.
635
+ * it, and by the reap sweep ({@link reapRevokedRows}). Plain scan with no `where`, so
636
+ * the composite-primary-key point-lookup hazard (see the statement comment in
637
+ * {@link reissueRevocations}) does not arise. Unlocked, like every other read.
638
+ *
639
+ * Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}): it retires nothing, so
640
+ * neither sweep may reap or re-sign it, and skipping it keeps `RevocationRow.tableName`
641
+ * a {@link RevocableTable}. Filtered on `TableName` alone, in TypeScript: `RowIsGone`
642
+ * admits no other row under `'Revocation'`.
497
643
  */
498
644
  queryRevocations(): Promise<RevocationRow[]>;
499
645
  /**
@@ -572,6 +718,10 @@ export declare class ControlDatabase {
572
718
  /**
573
719
  * Insert a strand into the control database using an owner signature.
574
720
  *
721
+ * Fails with a `Strand.Id` uniqueness violation when the id is already seated — the
722
+ * caller decides whether that is a duplicate of its own earlier write (idempotent) or a
723
+ * genuine conflict; see {@link isStrandIdConflict} and `CadreNode.publishStrand`.
724
+ *
575
725
  * The owner signs the canonical row-bound authorization message (see
576
726
  * {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
577
727
  * this strand's contents and cannot be transplanted onto an attacker-chosen row. The
@@ -600,10 +750,65 @@ export declare class ControlDatabase {
600
750
  * The remove digest binds only (Id, StampId) — not Type/MemberPrivateKey — so this
601
751
  * works identically for open and closed strands.
602
752
  *
753
+ * When the strand has a `StrandPartyKey` row (this party's own membership identity for
754
+ * the strand — minted at publish for closed strands), it is deleted IN THE SAME
755
+ * transaction, with its own `'remove'`-tagged signature and its own `Revocation`
756
+ * tombstone, so a re-published strand always mints fresh identity and no crash window
757
+ * can orphan the key row. The party-key delete happens ONLY alongside an actual strand
758
+ * row delete: a `StrandPartyKey` row with no local `Strand` row is a JOINER's identity
759
+ * (the joiner never holds the strand row) and is not this method's to destroy — that
760
+ * is {@link deleteStrandPartyKey}'s.
761
+ *
603
762
  * A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
604
763
  * when a row was actually removed.
605
764
  */
606
765
  deleteStrand(strandId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
766
+ /**
767
+ * The locked body of {@link deleteStrand}: the `Strand` delete + tombstone, plus — when
768
+ * one exists — the strand's `StrandPartyKey` delete + tombstone, all in ONE
769
+ * transaction. A two-table sibling of {@link deleteGuardedRow} (see that method for the
770
+ * per-clause security rationale: stamp read inside the locked body, `'remove'`-tagged
771
+ * digests, mandatory same-transaction tombstones); kept separate rather than
772
+ * generalizing the shared body because no other guarded table has a companion row.
773
+ *
774
+ * NOTE: that duplication is a drift risk, not a correctness one — a future tightening of
775
+ * {@link deleteGuardedRow}'s discipline (an extra precondition, a different digest shape)
776
+ * will NOT reach this body. Grep for both when you change either; if a second guarded
777
+ * table ever gains a companion row, generalize instead of copying this a third time.
778
+ *
779
+ * Not wrapped in {@link withWriteLock} — the caller holds the (non-re-entrant) lock.
780
+ */
781
+ private deleteStrandAndPartyKey;
782
+ /**
783
+ * Insert this party's own strand membership identity key (`StrandPartyKey` row) using
784
+ * an owner signature.
785
+ *
786
+ * Mirrors {@link insertStrand}: the owner signs the canonical row-bound authorization
787
+ * message over (Id, PrivateKey, StampId) — binding the key material means a captured
788
+ * approval can only ever reproduce the exact key it approved — and the StampId is
789
+ * persisted as a unique column for single-use anti-replay.
790
+ *
791
+ * @param strandId - The strand this key is the party's identity for.
792
+ * @param privateKey - The party's ed25519 strand member private key, base64 protobuf
793
+ * (same encoding as `Strand.MemberPrivateKey`; mint with `generateStrandMemberKey`).
794
+ * @param ownerKey - Public key of the authorizing owner.
795
+ * @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
796
+ * with the owner's private key, returning a base64url signature.
797
+ */
798
+ insertStrandPartyKey(strandId: string, privateKey: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<void>;
799
+ /**
800
+ * Owner-signed removal of one `StrandPartyKey` row — the party's own membership
801
+ * identity for that strand, stored nowhere else, so this is as destructive as
802
+ * {@link deleteStrand} on a closed strand. {@link deleteStrand} already removes the
803
+ * founder's row alongside the `Strand` row in one transaction; this standalone form
804
+ * exists for a party-key row with NO local `Strand` row (a joiner's identity — the
805
+ * shape the formation tickets build on).
806
+ *
807
+ * Mirrors {@link deleteValidationKey}: `'remove'`-tagged digest over (Id, StampId),
808
+ * `Revocation` tombstone in the same transaction. A no-op (no throw, no tombstone)
809
+ * when the row does not exist — `false` then, `true` when a row was actually removed.
810
+ */
811
+ deleteStrandPartyKey(strandId: string, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<boolean>;
607
812
  /**
608
813
  * Insert a validation key into the control database using an owner signature.
609
814
  *
@@ -797,6 +1002,13 @@ export declare class ControlDatabase {
797
1002
  * come from closed literal unions — no caller-supplied string reaches the statement.
798
1003
  */
799
1004
  private deleteGuardedRow;
1005
+ /**
1006
+ * Fire the guarded-delete listener for one committed tombstone, swallowing (and
1007
+ * logging) a listener throw — a committed delete never fails because bookkeeping did.
1008
+ * Shared by {@link deleteGuardedRow} and {@link deleteStrandAndPartyKey} (which files
1009
+ * up to two tombstones per transaction).
1010
+ */
1011
+ private notifyGuardedDelete;
800
1012
  /**
801
1013
  * Delete one guarded row that an ALREADY-COMMITTED `Revocation` tombstone retires —
802
1014
  * the local catch-up for a node that converged on a removal's tombstone while still
@@ -868,8 +1080,9 @@ export declare class ControlDatabase {
868
1080
  *
869
1081
  * Two rows are deliberately left alone:
870
1082
  *
871
- * - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `OwnerKey`) — no reap branch
872
- * exists on their `AuthorizedDelete`, so the delete would throw rather than no-op.
1083
+ * - **Tables outside {@link REAPABLE_TABLES}** (`Strand`, `StrandPartyKey`, `OwnerKey`)
1084
+ * — no reap branch exists on their `AuthorizedDelete`, so the delete would throw
1085
+ * rather than no-op.
873
1086
  * - **This node's OWN `CadrePeer` / `DeviceToken` row** (`selfPeerId`). Reaping it would
874
1087
  * fight this node's own re-registration: `registerSelf` / `retouchSelfDeviceToken` run
875
1088
  * on the heartbeat and the growth drain, and after a self-reap their insert-if-absent
@@ -889,8 +1102,11 @@ export declare class ControlDatabase {
889
1102
  * skip-self rule is exercisable without one.
890
1103
  *
891
1104
  * NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
892
- * one {@link queryStampId} point lookup per tombstone, and the empty-table early return
893
- * makes the common case (a party that has never revoked anyone) a single scan per pass.
1105
+ * one {@link queryStampId} point lookup per tombstone, and the empty early return makes
1106
+ * the common case (a party that has never revoked anyone; {@link queryRevocations} skips
1107
+ * the ledger marker) a single scan per pass. That scan consults the cohort on every pass
1108
+ * only while `Revocation` has never been written; the ledger marker
1109
+ * ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
894
1110
  * But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
895
1111
  * lookups on every reconcile tick. Fine while revocations stay rare for a cadre-sized
896
1112
  * party; if the table ever grows, persist a node-local high-water mark of what has
@@ -927,6 +1143,44 @@ export declare class ControlDatabase {
927
1143
  * Returns how many UPDATE statements ran (`rows.length` on success).
928
1144
  */
929
1145
  reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number, ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<number>;
1146
+ /**
1147
+ * Owner-signed filing of the singleton `Revocation` ledger marker
1148
+ * ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
1149
+ *
1150
+ * Why it exists: while `Revocation` has never been written, this node does not hold its
1151
+ * block, and Optimystic consults a block's cohort on EVERY read of a block it does not
1152
+ * hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
1153
+ * reads this table. Once any row exists the block is held and re-checked at most once per
1154
+ * read-repair window, like every other populated table. The schema's table comment says
1155
+ * why the row can never read as a retirement.
1156
+ *
1157
+ * Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
1158
+ * OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
1159
+ * runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
1160
+ * the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
1161
+ * full composite primary key, which is served as a point lookup that can miss an existing
1162
+ * row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
1163
+ *
1164
+ * Two owners filing at once: the loser either sees the marker in its guard or is refused
1165
+ * on the primary key, and both answer `'already-open'`.
1166
+ *
1167
+ * The caller decides WHEN. A marker committed while the node is alone is local-only and
1168
+ * can fork the collection, so {@link CadreNode} files it only while connected; this
1169
+ * method does not look at connectivity.
1170
+ *
1171
+ * @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
1172
+ * @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
1173
+ * private key, returning a base64url signature.
1174
+ * @returns `'opened'` when this call filed the marker, `'already-open'` when it was
1175
+ * already there.
1176
+ */
1177
+ openRevocationLedger(ownerKey: string, signMessage: (message: Uint8Array) => string): Promise<RevocationLedgerOpenResult>;
1178
+ /**
1179
+ * Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
1180
+ * stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
1181
+ * Called only inside the locked write body, so never retried (see queryStampId's NOTE).
1182
+ */
1183
+ private revocationLedgerFiled;
930
1184
  /**
931
1185
  * Run `body` between `beginTransaction` and `commit`, rolling back on failure.
932
1186
  *
@@ -967,6 +1221,17 @@ export declare class ControlDatabase {
967
1221
  * call replaces the first.
968
1222
  */
969
1223
  setGuardedDeleteListener(listener: GuardedDeleteListener | null): void;
1224
+ /**
1225
+ * Wire (or clear, with null) the single listener notified when {@link lockedWithRetry}
1226
+ * ABANDONS a control write. Same ownership contract as
1227
+ * {@link setMembershipChangeListener}: one CadreNode, wired in `start()` and cleared on
1228
+ * teardown, and a second call replaces the first rather than fanning out.
1229
+ *
1230
+ * Covers every write that reaches the funnel, foreground and background alike. A
1231
+ * foreground caller also sees the rethrown error; a background one often does not, which
1232
+ * is the whole reason this exists.
1233
+ */
1234
+ setControlWriteAbandonedListener(listener: ControlWriteAbandonedListener | null): void;
970
1235
  /**
971
1236
  * Run a `CadrePeer` row mutation and notify the membership listener once it has
972
1237
  * COMMITTED.
@@ -1040,6 +1305,13 @@ export declare class ControlDatabase {
1040
1305
  * attempt: a retry re-presents the exact signed message the first attempt presented.
1041
1306
  */
1042
1307
  withWriteLock<T>(fn: () => Promise<T>): Promise<T>;
1308
+ /**
1309
+ * Run one locked body, counted in {@link runningWriteBodies} from the moment it takes
1310
+ * the lock until it settles — so an unlocked read routes around it even while its
1311
+ * first statement is still waiting for the database's exec mutex, before
1312
+ * `getAutocommit()` can show it (see {@link readRowsOnce}).
1313
+ */
1314
+ private runWriteBody;
1043
1315
  /**
1044
1316
  * {@link withWriteLock} plus the bounded transient-failure retry
1045
1317
  * ({@link retryControlWrite}) — the wrapper the whole public write surface goes
@@ -1063,7 +1335,12 @@ export declare class ControlDatabase {
1063
1335
  * `label` names the operation in this loop's debug lines and nothing else
1064
1336
  * ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
1065
1337
  * several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
1066
- * attributed to a write — keep new call sites labelled.
1338
+ * attributed to a write — keep new call sites labelled. It is also the only thing that
1339
+ * attributes an abandonment reported through {@link setControlWriteAbandonedListener}.
1340
+ *
1341
+ * The abandonment observer lands LAST, after both the policy and the spec-injected
1342
+ * pacing, so this class's single listener is the one seam for it — a caller that
1343
+ * smuggled its own in through `policy` would be displaced here rather than fanning out.
1067
1344
  */
1068
1345
  private lockedWithRetry;
1069
1346
  /**
@@ -1312,22 +1589,19 @@ export declare class ControlDatabase {
1312
1589
  /**
1313
1590
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
1314
1591
  *
1315
- * This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
1316
- * the invitation never paid for. It is served by a table SCAN, deliberately: a bare
1317
- * equality on a non-key column is a shape the optimystic vtab declines to claim, so the
1318
- * engine applies the filter itself over a full read of the table.
1319
- *
1320
- * NOTE: it was briefly an index seek, and that is what the scan is protecting against.
1321
- * `formation-unique-token-redesign` took `Token` out of the primary key and declared
1322
- * `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
1323
- * under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
1324
- * not produce". The tripwire fired. Across machines a secondary-index descent returns only
1325
- * the rows the READING machine wrote, so this count under-reported without bound on a
1326
- * multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
1327
- * measurement is recorded in the block comment at the `FormationUsage` declaration in
1328
- * `schemas/control.qsql`, and the engine defect in
1329
- * `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
1330
- * speed this up while that ticket is open.
1592
+ * Served by a seek through the `FormationUsageByToken` index rather than a full scan of
1593
+ * the table, which is append-only and grows for the life of the party.
1594
+ *
1595
+ * NOTE: this count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a
1596
+ * seat the invitation never paid for — and reading it through a secondary index makes the
1597
+ * cap depend on that index converging across machines. It did not, from 2026-08-04 to
1598
+ * 2026-08-25: a descent on a second machine returned only the rows that machine had
1599
+ * written, and the index was removed until the engine was fixed upstream (re-measured
1600
+ * 2026-09-17, `complete/restore-formation-usage-token-index`). The live guard is the
1601
+ * integration-tests scenario `strand-formation-concurrent-redemption`, which asserts both
1602
+ * machines' views of a raced redemption. If it fails on BOTH views again, index
1603
+ * convergence has regressed — fix the engine or take this read off the index, and do not
1604
+ * weaken that scenario's assertions to get a green run.
1331
1605
  *
1332
1606
  * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
1333
1607
  * locked write body — same per-call opt-out, and for the same reason, as