@serfab/cadre-core 1.16.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +11 -2
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +652 -1052
  151. package/src/cadre-join-request.ts +24 -3
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +886 -789
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -1,567 +1,569 @@
1
- import { CoordinatorPartialCommitError, SyncRetryExhaustedError, TornActionError } from '@optimystic/db-core';
2
- import { PartialCommitError } from '@optimystic/quereus-plugin-optimystic';
3
- import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
4
- import type { ControlRetryOptions } from './control-retry.js';
5
-
6
- /**
7
- * Bounded retry for TRANSIENT control-write failures — the classifier and loop behind
8
- * `ControlDatabase.lockedWithRetry`, the single funnel every local control write passes
9
- * through.
10
- *
11
- * Why this exists: the control database replicates every block to the whole party, so a
12
- * three-machine party commits control writes unanimously (`ceil(3 × 0.75) = 3` approvals) —
13
- * and no `superMajorityThreshold` can lower that bar without violating Optimystic's
14
- * partition-safety condition at the shipped admission fraction (the arithmetic lives in
15
- * `docs/architecture.md` → "Replication cluster size"). Cadre therefore accepts unanimity
16
- * and absorbs the transient half of its cost here: a write that failed because the cohort
17
- * did not ANSWER (a stream reset mid-connection-formation, zero approvals) is re-presented
18
- * a moment later, which the degraded-cohort-member scenario measured as safe (a failed
19
- * write rolls back, nothing half-commits) and effective (the next write commits in ~1 s).
20
- *
21
- * A write somebody actually REJECTED is never retried on the strength of the rejection itself —
22
- * no matcher here claims a rejection message, at any phase, because re-presenting one would
23
- * re-present a spent signature against a cohort that already said no. Rejections are re-presented
24
- * anyway when they arrive inside the transactor's promise-phase `[block:` aggregate, which
25
- * {@link isUncommittedTransactorAggregate} claims on the WRAPPER alone, whatever the cause inside
26
- * says. Safe because nothing has committed at that phase, and deliberately kept — one of the
27
- * remaining promise-phase rejections is exactly the kind a re-presentation fixes. See the
28
- * accepted-tradeoff `NOTE:` there for which, and why no blanket rejection veto is wanted.
29
- *
30
- * A failure that says some of the write may be STORED is a different matter, and never
31
- * re-presented whatever text it carries: {@link reportsPossiblyStoredWrite} vetoes it by type
32
- * before any matcher runs. The one half-landed write that IS re-presented is a torn write the
33
- * library marks final — not saved and unable to land ({@link isFinalTornWrite}).
34
- *
35
- * TWO policies ship from here, and the split is deliberate: the default
36
- * ({@link isRetriableControlWriteFailure}, {@link CONTROL_WRITE_ATTEMPTS}) sits under every
37
- * control write, and {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
38
- * `ControlDatabase.loadSchema` alone. The widened one absorbs one extra failure class whose
39
- * re-run safety holds for schema DDL and NOT for writes in general; anything added to the default
40
- * list must be safe for all ~19 callers.
41
- *
42
- * Lives outside `control-database.ts` so a spec can drive the classifier against real engine
43
- * errors without importing the whole database class. The loop itself lives in
44
- * `control-retry.ts`, shared with the READ policy (`control-read-retry.ts`) — this module
45
- * owns the write policies: their classifiers, attempt counts, backoff and budget.
46
- */
47
-
48
- /**
49
- * Attempts allowed per control write: the first, plus two retries of a transiently-failed
50
- * cluster commit. Bounded so a genuinely unreachable cohort terminates in the transactor's
51
- * own error rather than spinning; two retries covers the observed failure (a stream still
52
- * forming when the write fired) with margin.
53
- */
54
- export const CONTROL_WRITE_ATTEMPTS = 3;
55
-
56
- /**
57
- * Attempts allowed for {@link SCHEMA_INIT_RETRY_POLICY}, the one call site that also absorbs a
58
- * self-coordination refusal.
59
- *
60
- * Two more than {@link CONTROL_WRITE_ATTEMPTS} because what this retry buys is wall-clock for a
61
- * cold-starting node's bootstrap dials to land, and because the refusal is cheap to re-provoke
62
- * relative to a cluster round-trip: `findCoordinator` already spends its own bounded wait before
63
- * raising (3 attempts, 500 ms apart, `db-p2p/src/libp2p-key-network.ts` ~422) and then decides
64
- * LOCALLY that it may not elect itself, so no peer is contacted and no cohort deadline is
65
- * consumed.
66
- *
67
- * Note that the ~1 s `findCoordinator` spends internally is real, on top of this policy's own
68
- * backoff — which is exactly why {@link CONTROL_WRITE_RETRY_BUDGET_MS} and not this count is what
69
- * actually terminates the loop in the worst case.
70
- */
71
- export const SCHEMA_INIT_ATTEMPTS = 5;
72
-
73
- /**
74
- * Elapsed-time ceiling on the whole retry loop, measured from the START of the first
75
- * attempt and checked after a failed attempt BEFORE sleeping.
76
- *
77
- * This budget — not the attempt count — is what makes the policy safe to sit under every
78
- * control write. A transient failure (stream reset while a connection is still forming)
79
- * surfaces in well under a second, so it is retried and the loop adds at most ~2.2 s. A
80
- * genuinely silent cohort member costs ~21 s per consensus round (two `ClusterClient`
81
- * response-deadline attempts, 10.5 s each at the default declared link): one pend round plus
82
- * the cancel discharge's three rounds, about 84 s for one write in
83
- * `control-write-degraded-cohort-member.integration.ts`, which already exceeds this budget
84
- * when attempt 1 returns — so that case is surfaced immediately and retry adds ZERO latency
85
- * to the case where it cannot help.
86
- *
87
- * It cuts the loop off by design, so it does not grow with the link: its job is to end the
88
- * retries of slow attempts, and the caller gets the last attempt's error unchanged. An attempt
89
- * whose read phase consulted a silent peer ends at about one per-peer read deadline
90
- * (`cohortReadDeadlineMs` in `link-budget.ts`: 7 s at the default declared link, 5 s before it
91
- * was derived, 1 s at Optimystic's own default), so it still gets one retry inside this budget
92
- * where it once got two; an attempt that runs into the `ClusterClient` response deadline
93
- * (10.5 s at the default declared link) still gets none. See `docs/cadre-consistency.md` →
94
- * "Deadlines Over Optimystic's Reads and Commits".
95
- *
96
- * NOTE: a failed commit attempt also pays a cancel discharge before its error returns
97
- * (optimystic `TransactorSource.transact`'s catch) — bounded by six rounds and
98
- * `abortOrCancelTimeoutMs`, which every collection this repo opens sets to the larger of 5 s
99
- * and the RPC dial deadline: 42.5 s at the default declared link (`optimysticDialLimits` in
100
- * `link-budget.ts`;
101
- * `../optimystic/packages/quereus-plugin-optimystic/src/optimystic-adapter/collection-factory.ts`).
102
- * One cancel that runs its full budget therefore ends the retries on its own, cutting the
103
- * three-attempt policy to however many attempts came before it. A cancel only runs that long
104
- * when a peer stays silent, and that attempt has already spent its pend round past this
105
- * budget, so the cut loses nothing there. After a transient fault the cancel is either
106
- * answered or gives up after its six rounds against a dead transport, in about a second
107
- * either way (optimystic `NetworkTransactor.MAX_CANCEL_ROUNDS`). Not observed — every
108
- * measured round of the transient-reset case committed on attempt 3 of 3 — so this is a
109
- * condition to watch, not work to do: if that case ever starts failing with
110
- * `failed after 2/3 attempt(s)`, the cancel discharge is where the time went.
111
- */
112
- // eslint-disable-next-line no-restricted-syntax -- cuts off by design: it ends the retries of slow attempts rather than waiting them out; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
113
- export const CONTROL_WRITE_RETRY_BUDGET_MS = 10_000;
114
-
115
- /**
116
- * Backoff before retry N, jittered ±50% (so the first sleep is 125–375 ms — the jitter can
117
- * never floor a delay to zero). Each single sleep is additionally capped at the LARGEST
118
- * delay in the list, so a `stop()` racing a backoff is never held up by more than ~1 s.
119
- * There is no `AbortSignal` at this seam to cancel a sleep outright; the cap is the
120
- * substitute, deliberately not plumbing one through every write path.
121
- */
122
- const CONTROL_WRITE_RETRY_DELAYS_MS: readonly number[] = [250, 1_000];
123
-
124
- /**
125
- * Backoff for {@link SCHEMA_INIT_RETRY_POLICY}, jittered and capped the same way. Worst case
126
- * ~4.6 s of sleep across four retries (375 + 750 + 1500 + 2000, each capped at the largest base),
127
- * which stays inside {@link CONTROL_WRITE_RETRY_BUDGET_MS} — the budget still terminates the loop
128
- * first if any single attempt is itself slow.
129
- *
130
- * Sized against measured facts, not guessed:
131
- *
132
- * - the self-coordination guard clears the moment the node has ONE connection again (both
133
- * branches that raise `grace-period-not-elapsed` require `getConnections().length === 0`), not
134
- * when the 30 s grace period expires — so this does NOT need to cover the grace period, only
135
- * the gap until a bootstrap dial completes;
136
- * - the guard's 30 s `gracePeriodMs` cannot be tuned from this repo anyway: no caller in either
137
- * repo passes a `SelfCoordinationConfig`, so the default is always in force;
138
- * - a node that has NEVER connected is waved through as a bootstrap node (network high-water
139
- * mark of 1), so this retry only ever delays a node that HAS seen peers. One whose peers are
140
- * gone for good still fails, ~4.6 s later — which is why the budget stays at the shared
141
- * ceiling rather than stretching to cover the full 30 s window.
142
- */
143
- const SCHEMA_INIT_RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000];
144
-
145
- /** The network transactor's aggregate, raised when some cohort peers gave no usable answer. */
146
- const TRANSACTOR_AGGREGATE = /Some peers did not complete:/;
147
-
148
- /**
149
- * How the aggregate's per-batch details name a SINGLE-block batch — the shape both the block
150
- * read (`get`) and phase 1 (`pend`) format, and the only shape safe to re-present.
151
- */
152
- const SINGLE_BLOCK_BATCH_TOKEN = '[block:';
153
-
154
- /**
155
- * How the aggregate's per-batch details name a MULTI-block batch — the shape phase 2
156
- * (`commitBlocks`) formats, and the marker of an outcome too indeterminate to retry.
157
- */
158
- const COMMIT_BATCH_TOKEN = '[blocks:';
159
-
160
- /**
161
- * The cluster coordinator's super-majority shortfall (`db-p2p/src/repo/cluster-coordinator.ts`),
162
- * matched ONLY with a ZERO rejection count. The same message with a non-zero count means a
163
- * member actually voted no (that branch carries `membership-not-admitted` rejections), and
164
- * retrying it would re-present a spent signature to a cohort that already refused it. This one
165
- * is raised while collecting PROMISES, before any commit, so re-presenting it is safe.
166
- *
167
- * This regex itself never matches a decisive rejection (`ValidatorRejectionError`, `Transaction
168
- * rejected by validators`) — that text carries no `Failed to get super-majority:` prefix. But the
169
- * composite classifier is not this matcher alone: a rejection raised while promises are still
170
- * being collected reaches this repo wrapped in the transactor's `Some peers did not complete:
171
- * …[block:…]` aggregate, and {@link isUncommittedTransactorAggregate} matches that wrapper on its
172
- * own, whatever the cause inside it says — see the accepted-tradeoff `NOTE:` there.
173
- */
174
- const SUPER_MAJORITY_SHORTFALL_UNANSWERED =
175
- /Failed to get super-majority: \d+\/\d+ approvals \(needed \d+, 0 rejections\)/;
176
-
177
- /**
178
- * Is this a transactor aggregate from a phase where nothing can have committed yet?
179
- *
180
- * The transactor raises `Some peers did not complete:` from three sites
181
- * (`db-core/src/transactor/network-transactor.ts`), and only two of them are safe to
182
- * re-present:
183
- *
184
- * - `get` (a block read, which a write body also performs) and `pend` (phase 1) fail before
185
- * anything commits, so the write is known not to have landed;
186
- * - `commitBlocks` (phase 2) is REACHABLE here and NOT safe. `commitBlock` throws the
187
- * aggregate when a header/tail commit got NO response at all — precisely the transient
188
- * class this retry targets — and that escapes `commit()`, survives `TransactorSource`'s
189
- * cancel-and-rethrow (cancel reaches PENDING actions only; a peer that already committed
190
- * stays committed) and `Collection.syncInternal` (which retries StaleFailure RETURN values,
191
- * not throws), reaching this funnel QuereusError-wrapped. The tail commit is one batch to
192
- * one coordinator running consensus internally, so a no-response there is INDETERMINATE:
193
- * the commit may have completed with only the response lost. Re-running the write body over
194
- * a write that landed turns a success into a constraint failure (e.g.
195
- * `UNIQUE constraint failed: CadrePeer.PeerId`). Surfacing the transient error instead lets
196
- * the caller re-read committed state and decide. Commit-phase messages are vetoed by
197
- * {@link reportsIndeterminateCommit}, not here.
198
- *
199
- * The `cause` chain does not discriminate the phases — a commit-phase aggregate carries the
200
- * same stream-reset/dial error a pend-phase one does. The per-batch DETAIL text does: `get`
201
- * and `pend` format `<peerId>[block:<id>](<status>)`, `commitBlocks` formats
202
- * `<peerId>[blocks:<count>](<status>)`. `[block:` cannot occur inside `[blocks:`, so the two
203
- * tokens are disjoint and separate the phases.
204
- *
205
- * NOTE: the discriminator is a formatting detail of another repo. If Optimystic ever reformats
206
- * those per-batch details, this fails CLOSED — the aggregate stops matching and control writes
207
- * simply stop being retried, silently losing the absorption rather than doing anything unsafe.
208
- * The guard against that is live: `control-write-degraded-cohort-member.integration.ts` asserts
209
- * this classifier against the real degraded-cohort failure object and asserts the `[block:`
210
- * token on the live aggregate; if it reddens there, an upstream reformat is the first thing to
211
- * check.
212
- *
213
- * NOTE: the discriminator is the prefix AND the token within ONE message — never relax it to
214
- * "contains `[block:`". Beyond the `get` and `pend` sites above, Optimystic renders `[block:` in
215
- * one further place — a third formatter, outside the aggregate's three sites — `dischargeCancel`
216
- * (`Cancel of action <id> did not discharge <n> block(s): …`), which does not raise the
217
- * `Some peers did not complete:` prefix; only the conjunction keeps a cancel fault from
218
- * classifying as a retriable get/pend (checked against optimystic `c56c2bd4`, 2026-09-16).
219
- *
220
- * NOTE: accepted tradeoff — a REJECTION raised while promises are still being collected reaches
221
- * this repo wrapped in this same aggregate, and this matcher claims it on the wrapper alone,
222
- * whatever the cause inside says. That re-presents such a rejection up to two more times inside
223
- * the 10 s budget. Kept deliberately, and re-decided on 2026-09-17 against the current upstream
224
- * behaviour rather than inherited:
225
- *
226
- * - Contention is no longer a rejection at all. "Another write holds this block right now"
227
- * (`pending conflict`) is now a `held` verdict that counts toward neither approvals nor
228
- * rejections (`validatePendOperations`,
229
- * `../optimystic/packages/db-p2p/src/cluster/cluster-repo.ts`), so the rejection this tradeoff
230
- * was originally written about does not arrive any more. It reaches this funnel as
231
- * `SyncRetryExhaustedError` instead, which {@link reportsPossiblyStoredWrite} declines by type
232
- * — upstream documents resubmitting after it as unsafe (the attempt that spent the budget may
233
- * have left its log entry standing), and its collection sync has already spent ten retries on
234
- * the race. The torn-write rule beside it: a `TornActionError` is re-presented only when
235
- * `final` is true; any other torn write is declined.
236
- * - What remains rejectable at the promise phase is stale revision, block-unavailable,
237
- * membership-not-admitted, and a configured validator's refusal. Only the first is worth a
238
- * retry — and it is worth one: this loop re-runs the WHOLE write body, reads included
239
- * (`ControlDatabase.withWriteLock`'s contract), so attempt 2 presents a pend against the
240
- * revision that made attempt 1 stale. A blanket "decline any chain reporting a validator
241
- * rejection" rule would remove that retry to save two wasted attempts on the other three.
242
- *
243
- * Revisit if a re-presentation ever becomes expensive relative to what it buys — a rejection class
244
- * that costs a full cohort round-trip to re-present, or a write body whose reads are no longer
245
- * cheap — or when upstream offers a typed refusal surface to classify on instead of this text
246
- * (`../optimystic/tickets/backlog/debt-a-downstream-repo-classifies-retries-by-parsing-our-error-text`).
247
- * Contention is no longer the reason to revisit.
248
- *
249
- * An aggregate whose details came out EMPTY (possible when `formatBatchStatuses` has no
250
- * batches to format) carries neither token, matches nothing here, and is not retried — an
251
- * unattributable failure is not a proven non-commit.
252
- *
253
- * Exported for the READ classifier (`control-read-retry.ts`), which reuses it verbatim: a
254
- * `get` batch formats the same single-block `[block:` token, and a read can never produce
255
- * the commit-phase `[blocks:` shape at all.
256
- */
257
- export function isUncommittedTransactorAggregate(message: string): boolean {
258
- return TRANSACTOR_AGGREGATE.test(message) && message.includes(SINGLE_BLOCK_BATCH_TOKEN);
259
- }
260
-
261
- /**
262
- * Does ANY message in the failure's `cause` chain describe a commit-phase batch — i.e. an
263
- * outcome nobody can call committed or not?
264
- *
265
- * Vetoes the whole chain rather than the one message carrying the token, and vetoes it
266
- * regardless of which matcher would otherwise have claimed it. The narrower per-message rule
267
- * would already catch every shape the transactor is known to build (a commit-phase aggregate
268
- * emits `[blocks:` for every batch it formats, and embeds its cause's message inline as
269
- * `root: …`, so both tokens land in one string) — but "known to build" is an argument about
270
- * another repo's error assembly, and this way an indeterminate commit anywhere in the chain
271
- * costs a retry we could have had instead of risking a re-run over a write that landed.
272
- */
273
- function reportsIndeterminateCommit(messages: readonly string[]): boolean {
274
- return messages.some(message => message.includes(COMMIT_BATCH_TOKEN));
275
- }
276
-
277
- /**
278
- * Does any link of the `cause` chain say some of this write may be stored, or may still be?
279
- * Such a failure is never re-presented — a re-run could store the write twice.
280
- *
281
- * - `SyncRetryExhaustedError` (and its `SyncRevisionStalledError` subclass): upstream documents
282
- * resubmitting as unsafe, because the attempt that spent the budget may have left its log
283
- * entry standing.
284
- * - `TornActionError` without `final: true`: the write's log entry is stored and nobody could
285
- * establish that the rest can never land — it may be saved already, or land later.
286
- * - `CoordinatorPartialCommitError` / `PartialCommitError`: another collection or tree of the
287
- * same transaction is already durable, and re-running the whole body would apply it twice.
288
- *
289
- * Every one of these embeds text from elsewhere in its message (a responder's refusal, the
290
- * underlying failure), which can carry the pend-phase aggregate's prefix and `[block:` token —
291
- * so this veto runs before any text matcher.
292
- *
293
- * Matched by TYPE: each survives the bridge's and Quereus' rewraps on `cause`. An error built by a
294
- * second loaded copy of `@optimystic/db-core` (or of the plugin, for `PartialCommitError`) fails
295
- * `instanceof`, and then this veto does not fire — falling back to the text classifiers, which is
296
- * how these failures were classified before the veto existed. The asymmetry with
297
- * {@link isFinalTornWrite} is deliberate: there, a missed `instanceof` means no retry, which is the
298
- * safe side. No text fallback parses `TornActionError`'s closing sentence, since upstream says its
299
- * wording is for log lines only.
300
- *
301
- * NOTE: the plugin is registered through its `/plugin` entry (`control-database.ts`) but
302
- * `PartialCommitError` is imported from its root entry; the two are one class only because the
303
- * plugin's build emits both entries over one shared chunk. If upstream ever bundles the entries
304
- * separately, this `instanceof` stops matching with every spec still green — import the class from
305
- * the entry the plugin is registered through.
306
- */
307
- function reportsPossiblyStoredWrite(links: readonly Error[]): boolean {
308
- return links.some(link =>
309
- link instanceof SyncRetryExhaustedError
310
- || (link instanceof TornActionError && link.final !== true)
311
- || link instanceof CoordinatorPartialCommitError
312
- || link instanceof PartialCommitError);
313
- }
314
-
315
- /**
316
- * A torn write the library marks FINAL: not saved, unable to land, its pending records confirmed
317
- * cancelled — so submitting it again stores it once.
318
- *
319
- * Worth re-presenting, not merely safe to: upstream raises it mostly when a rival holds the
320
- * revision this write's log entry claimed, which is contention, and the loop re-runs the write
321
- * body's reads, so the next attempt builds on the rival's revision — the same argument that keeps
322
- * stale-revision rejections retried (the accepted-tradeoff `NOTE:` on
323
- * {@link isUncommittedTransactorAggregate}).
324
- *
325
- * NOTE: `CoordinatorStaleLossError` (db-core; "nothing durably committed, safe to re-drive") is not
326
- * claimed, so it falls to the text matchers; it escapes only after the coordinator's own retry budget
327
- * ran out. Seen abandoning a control write on 2026-09-18 (`control-delete-while-alone-convergence`,
328
- * under load): a node's own storage refused its own pend as a `stale conflict` for the ~14 s the
329
- * coordinator spent re-driving, then gave up. Still not claimed here — the failing attempt already
330
- * ran past {@link CONTROL_WRITE_RETRY_BUDGET_MS}, so a retry from this loop would never get to run,
331
- * and the refusal came from the node's OWN storage disagreeing with its own revision view, which does
332
- * not change while the node is alone; re-driving the same write body again would hit the same
333
- * refusal. The cause (a commit torn by a sibling that stopped mid-commit, leaving the node with a
334
- * revision view its own storage disputes) is `tickets/blocked/forked-control-collection-sync-livelocks.md`
335
- * → "Second trigger".
336
- */
337
- function isFinalTornWrite(link: Error): boolean {
338
- return link instanceof TornActionError && link.final === true;
339
- }
340
-
341
- /** The coordinator's shortfall, raised pre-commit, with nobody having voted no. */
342
- function isUnansweredSuperMajorityShortfall(message: string): boolean {
343
- return SUPER_MAJORITY_SHORTFALL_UNANSWERED.test(message);
344
- }
345
-
346
- /**
347
- * Optimystic refusing to let a node elect ITSELF coordinator because its last connection dropped
348
- * less than the guard's grace period ago (`Libp2pKeyPeerNetwork.shouldAllowSelfCoordination` →
349
- * `findCoordinator`, `db-p2p/src/libp2p-key-network.ts`).
350
- *
351
- * Matched ONLY with the `grace-period-not-elapsed` reason. The same sentence is raised with three
352
- * other reasons and none of them is worth a retry: `disabled` is static configuration, and
353
- * `partition-detected` / `suspicious-shrinkage` describe a network condition that will not resolve
354
- * inside a few seconds of backoff. The grace-period one WILL — it clears as soon as the node holds
355
- * one connection again, which a cold-starting node dialling its bootstrap peers is actively
356
- * working on.
357
- *
358
- * NOTE: matched by TEXT, not by type. `FindCoordinatorError` (`code: 'SELF_COORDINATION_BLOCKED'`)
359
- * is exported from `@optimystic/db-p2p`, and when this matcher was written the error OBJECT did not
360
- * survive the trip: `OptimysticVirtualTable.initialize` rethrew as `new Error(message)` with no
361
- * `cause`. It now rethrows through `rewrapAsQueryError`, which keeps `cause`
362
- * (`quereus-plugin-optimystic/src/optimystic-module.ts`); whether every Quereus wrapper above it on
363
- * the schema-init path keeps it too has not been checked, and the text still arrives either way.
364
- * Like every matcher here this fails CLOSED — a rewording upstream stops the retry engaging, it
365
- * never makes it unsafe.
366
- */
367
- const SELF_COORDINATION_GRACE_REFUSAL =
368
- /Self-coordination blocked: grace-period-not-elapsed\. No coordinator available for key\./;
369
-
370
- /** Self-coordination refused because the node's last connection dropped moments ago. */
371
- function isSelfCoordinationGraceRefusal(message: string): boolean {
372
- return SELF_COORDINATION_GRACE_REFUSAL.test(message);
373
- }
374
-
375
- /**
376
- * The error messages that mean "the cohort did not answer AND nothing committed", and nothing
377
- * else. The observed one-shot failure was `registerSelf()` racing a connection still forming:
378
- * a read/pend-phase transactor aggregate with `cause=The stream has been reset`.
379
- *
380
- * Deliberately NOT matched: every constraint / authorization failure
381
- * (`CHECK constraint failed:`, `UNIQUE constraint failed:`) — those reach the same funnel
382
- * and must be retried zero times: the cohort REFUSED the write, so re-presenting it
383
- * re-presents a spent signature and can never commit.
384
- */
385
- const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[] = [
386
- isUncommittedTransactorAggregate,
387
- isUnansweredSuperMajorityShortfall,
388
- ];
389
-
390
- /**
391
- * The above PLUS the self-coordination grace refusal — the classifier for schema init ONLY
392
- * ({@link SCHEMA_INIT_RETRY_POLICY}), deliberately not folded into the list above.
393
- *
394
- * Why this class is safe HERE and not everywhere: a write refused at coordinator SELECTION never
395
- * reached a peer, so re-presenting it is a proven non-commit. But `findCoordinator` is also
396
- * reached from `NetworkTransactor.commitBlock` during PHASE 2 (`resolveCoordinator`,
397
- * `db-core/src/transactor/network-transactor.ts`), and `commit()` commits the header block before
398
- * the rest — so a general control write refused there can be refused AFTER something already
399
- * committed. Worse, that error carries no `[blocks:` batch token, so
400
- * {@link reportsIndeterminateCommit} would not veto it, and re-running an insert body over a write
401
- * that landed is exactly the `UNIQUE constraint failed: CadrePeer.PeerId` failure that veto exists
402
- * to prevent.
403
- *
404
- * Schema init does not have that problem: `apply schema` is a diff rather than a replay, and a
405
- * failed apply is taken back whole — Quereus unwinds every step it ran and verifies the catalog
406
- * against a pre-apply fingerprint — so a re-run emits exactly the DDL the live catalog is missing.
407
- * The full argument, including the plugin-side dependency that unwind carries, is at the
408
- * `loadSchema` call site in `control-database.ts`.
409
- *
410
- * RETRIED DELIBERATELY, not by oversight: the failures Quereus cannot take back. When the unwind
411
- * does not complete — an undo statement threw, the post-unwind catalog did not match the pre-apply
412
- * fingerprint, that catalog could not be re-collected to check, or a step the differ marks
413
- * irreversible because it discards data (dropping a table or a column, narrowing a column's type —
414
- * none of which a `CadreControl` diff generates today) poisoned the journal before it ran, leaving
415
- * nothing unwound — Quereus keeps the failing step's own message, APPENDS the reason the
416
- * schema could not be restored, and carries the original as `cause`. So a transient cause
417
- * underneath still matches here, and neither veto ({@link reportsPossiblyStoredWrite},
418
- * {@link reportsIndeterminateCommit}) looks for that reason text. Retrying over a partially migrated
419
- * schema is safe for the same reason the ordinary re-run is: the plugin commits whatever its write
420
- * batch holds, so storage and the catalog are left in step either way, and the next apply diffs
421
- * against what is really there. Vetoing it instead would turn a healable transient outage into a
422
- * dead start.
423
- */
424
- const RETRIABLE_SCHEMA_INIT_MATCHERS: readonly ((message: string) => boolean)[] = [
425
- ...RETRIABLE_CONTROL_WRITE_MATCHERS,
426
- isSelfCoordinationGraceRefusal,
427
- ];
428
-
429
- /**
430
- * Did this control write fail because the cluster cohort did not ANSWER — i.e. is
431
- * re-presenting the SAME signed write a moment later the right response?
432
- *
433
- * Classifies the transient cases by MESSAGE, walking the `cause` chain with the shared
434
- * {@link chainMessages} (Quereus' own `unwrapError` underneath) — the transactor and the
435
- * coordinator both throw bare `Error`s, not recognisable by type. By the time one reaches
436
- * `ControlDatabase` it is wrapped in a `QuereusError` (the real chain is `QuereusError` → `Error`
437
- * → `Error`), so the match must work at any depth. Anything that is not an `Error` is never
438
- * retried.
439
- *
440
- * Two vetoes run over the whole chain before any matcher: {@link reportsPossiblyStoredWrite}
441
- * (by type — a spent sync budget, a non-final torn write, a partial commit) and
442
- * {@link reportsIndeterminateCommit} (by text — a commit-phase batch). A chain either veto flags,
443
- * at any level, is never retried on the strength of some other level looking transient. The one
444
- * typed failure claimed as retriable is a final torn write ({@link isFinalTornWrite}).
445
- *
446
- * NOTE: this depends on engine/transactor error TEXT. The messages producible without a
447
- * network are driven from the REAL engine in `control-formation-seat-budget.spec.ts`, so
448
- * a rewording reddens a spec rather than silently disabling the retry. The two RETRIABLE
449
- * messages need a real multi-node cluster to produce; the degraded-cohort scenario
450
- * (`control-write-degraded-cohort-member.integration.ts`) asserts this classifier against
451
- * both LIVE — the super-majority shortfall from a real silent member, and the transactor
452
- * aggregate from a real stream reset, which the retry there absorbs end to end.
453
- */
454
- export function isRetriableControlWriteFailure(error: unknown): boolean {
455
- return matchesRetriableFailure(error, RETRIABLE_CONTROL_WRITE_MATCHERS);
456
- }
457
-
458
- /**
459
- * {@link isRetriableControlWriteFailure} widened by exactly one class — the self-coordination
460
- * grace refusal ({@link RETRIABLE_SCHEMA_INIT_MATCHERS}) — for `ControlDatabase.loadSchema`.
461
- *
462
- * Opt-in per call site, via {@link SCHEMA_INIT_RETRY_POLICY}. Every other control write keeps the
463
- * default classifier untouched.
464
- */
465
- export function isRetriableSchemaInitFailure(error: unknown): boolean {
466
- return matchesRetriableFailure(error, RETRIABLE_SCHEMA_INIT_MATCHERS);
467
- }
468
-
469
- /**
470
- * Shared body of both classifiers: walk the `cause` chain, let {@link reportsPossiblyStoredWrite}
471
- * and then {@link reportsIndeterminateCommit} veto it, then claim a final torn write
472
- * ({@link isFinalTornWrite}), then ask `matchers`. The vetoes run BEFORE any matcher for every
473
- * policy — a chain reporting a possibly-stored write or a commit-phase batch is never retried,
474
- * however transient some other level looks, and a final torn write carried by a partial commit is
475
- * still declined because a sibling collection landed.
476
- */
477
- function matchesRetriableFailure(
478
- error: unknown,
479
- matchers: readonly ((message: string) => boolean)[]
480
- ): boolean {
481
- if (!(error instanceof Error)) {
482
- return false;
483
- }
484
- const links = causeChain(error);
485
- if (reportsPossiblyStoredWrite(links)) {
486
- return false;
487
- }
488
- const messages = chainMessages(error);
489
- if (reportsIndeterminateCommit(messages)) {
490
- return false;
491
- }
492
- if (links.some(isFinalTornWrite)) {
493
- return true;
494
- }
495
- return messages.some(message => matchers.some(matches => matches(message)));
496
- }
497
-
498
- /**
499
- * Policy and pacing overrides for {@link retryControlWrite}. Production callers pass nothing
500
- * (or a named policy such as {@link SCHEMA_INIT_RETRY_POLICY}); specs inject a recorded
501
- * `sleep`, a shorter delay list, or a fake clock so no test ever waits out a real backoff.
502
- *
503
- * Every field defaults to the shipped control-write policy ({@link CONTROL_WRITE_ATTEMPTS},
504
- * {@link CONTROL_WRITE_RETRY_DELAYS_MS}, {@link isRetriableControlWriteFailure}), so an
505
- * omitted field is byte-identical to the behaviour before per-call-site policies existed.
506
- * The field shapes and semantics live on the shared {@link ControlRetryOptions}.
507
- */
508
- export type ControlWriteRetryOptions = ControlRetryOptions;
509
-
510
- /**
511
- * The one non-default retry policy: `ControlDatabase.loadSchema`'s distributed DDL, which
512
- * additionally absorbs optimystic's self-coordination grace refusal and gets more attempts over a
513
- * longer backoff to do it (see {@link SCHEMA_INIT_ATTEMPTS} and
514
- * {@link SCHEMA_INIT_RETRY_DELAYS_MS}). Still bounded by the shared
515
- * {@link CONTROL_WRITE_RETRY_BUDGET_MS}.
516
- *
517
- * NOTE: exactly one call site opts in today, and the re-run safety this policy assumes is that
518
- * site's, not a general property — `apply schema` is a diff, and a failed apply is unwound whole
519
- * and verified against the pre-apply catalog (full argument at the `loadSchema` call site in
520
- * `control-database.ts`). A second opt-in must re-derive that argument for its own write body
521
- * first; if this policy ever grows a third consumer, rename it for what the callers share rather
522
- * than widening it by default.
523
- */
524
- export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
525
- attempts: SCHEMA_INIT_ATTEMPTS,
526
- delaysMs: SCHEMA_INIT_RETRY_DELAYS_MS,
527
- isRetriable: isRetriableSchemaInitFailure,
528
- };
529
-
530
- /**
531
- * Run `attempt` up to {@link CONTROL_WRITE_ATTEMPTS} times (or `options.attempts`), retrying only
532
- * failures the policy's classifier — {@link isRetriableControlWriteFailure} by default —
533
- * calls transient, and only while {@link CONTROL_WRITE_RETRY_BUDGET_MS} has not elapsed.
534
- *
535
- * `attempt` must be atomic and re-runnable: a failed cluster write rolls back (nothing is
536
- * half-applied), and re-running the body re-runs its reads too — `ControlDatabase`'s
537
- * locked write bodies all satisfy this (see the contract on
538
- * `ControlDatabase.withWriteLock`). The backoff sleeps happen with NO lock held; the caller
539
- * takes and releases its lock inside `attempt`.
540
- *
541
- * On exhaustion (attempts or budget) the LAST error is rethrown unchanged — never wrapped,
542
- * so the exact messages downstream code and the degraded-cohort-member scenario assert on
543
- * survive. A non-retriable failure propagates from the attempt that raised it.
544
- *
545
- * Either way — declined or exhausted — an `options.onAbandon` observer is notified exactly
546
- * once before the rethrow (a `ControlRetryAbandonment`). Only WRITES pass one: an abandoned
547
- * read throws to a caller that is awaiting it, so nothing is lost silently there
548
- * (`control-read-retry.ts` says so at its own seam), while an abandoned write may have no
549
- * caller at all — a background self-record republish is `void`-ed with a `debug` catch, so
550
- * its failure reaches nobody unless something is listening here.
551
- * `ControlDatabase.setControlWriteAbandonedListener` is the only thing that passes one in
552
- * production; `retryControlWrite` itself neither supplies nor requires an observer.
553
- */
554
- export function retryControlWrite<T>(
555
- attempt: () => Promise<T>,
556
- options: ControlWriteRetryOptions = {}
557
- ): Promise<T> {
558
- return retryControlOperation(attempt, {
559
- attempts: CONTROL_WRITE_ATTEMPTS,
560
- delaysMs: CONTROL_WRITE_RETRY_DELAYS_MS,
561
- budgetMs: CONTROL_WRITE_RETRY_BUDGET_MS,
562
- isRetriable: isRetriableControlWriteFailure,
563
- // The default prefix, named anyway: the degraded-cohort scenario asserts on
564
- // `Control write [<label>] …` lines byte-for-byte.
565
- logPrefix: 'Control write',
566
- }, options);
567
- }
1
+ import { CoordinatorPartialCommitError, SyncRetryExhaustedError, TornActionError } from '@optimystic/db-core';
2
+ import { PartialCommitError } from '@optimystic/quereus-plugin-optimystic';
3
+ import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
4
+ import type { ControlRetryOptions } from './control-retry.js';
5
+
6
+ /**
7
+ * Bounded retry for TRANSIENT control-write failures — the classifier and loop behind
8
+ * `ControlDatabase.lockedWithRetry`, the single funnel every local control write passes
9
+ * through.
10
+ *
11
+ * Why this exists: the control database replicates every block to the whole party, so a
12
+ * three-machine party commits control writes unanimously (`ceil(3 × 0.75) = 3` approvals) —
13
+ * and no `superMajorityThreshold` can lower that bar without violating Optimystic's
14
+ * partition-safety condition at the shipped admission fraction (the arithmetic lives in
15
+ * `docs/architecture.md` → "Replication cluster size"). Cadre therefore accepts unanimity
16
+ * and absorbs the transient half of its cost here: a write that failed because the cohort
17
+ * did not ANSWER (a stream reset mid-connection-formation, zero approvals) is re-presented
18
+ * a moment later, which the degraded-cohort-member scenario measured as safe (a failed
19
+ * write rolls back, nothing half-commits) and effective (the next write commits in ~1 s).
20
+ *
21
+ * A write somebody actually REJECTED is never retried on the strength of the rejection itself —
22
+ * no matcher here claims a rejection message, at any phase, because re-presenting one would
23
+ * re-present a spent signature against a cohort that already said no. Rejections are re-presented
24
+ * anyway when they arrive inside the transactor's promise-phase `[block:` aggregate, which
25
+ * {@link isUncommittedTransactorAggregate} claims on the WRAPPER alone, whatever the cause inside
26
+ * says. Safe because nothing has committed at that phase, and deliberately kept — one of the
27
+ * remaining promise-phase rejections is exactly the kind a re-presentation fixes. See the
28
+ * accepted-tradeoff `NOTE:` there for which, and why no blanket rejection veto is wanted.
29
+ *
30
+ * A failure that says some of the write may be STORED is a different matter, and never
31
+ * re-presented whatever text it carries: {@link reportsPossiblyStoredWrite} vetoes it by type
32
+ * before any matcher runs. The one half-landed write that IS re-presented is a torn write the
33
+ * library marks final — not saved and unable to land ({@link isFinalTornWrite}).
34
+ *
35
+ * TWO policies ship from here, and the split is deliberate: the default
36
+ * ({@link isRetriableControlWriteFailure}, {@link CONTROL_WRITE_ATTEMPTS}) sits under every
37
+ * control write, and {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
38
+ * `ControlDatabase.applyControlSchema` alone. The widened one absorbs one extra failure class whose
39
+ * re-run safety holds for schema DDL and NOT for writes in general; anything added to the default
40
+ * list must be safe for all ~19 callers.
41
+ *
42
+ * Lives outside `control-database.ts` so a spec can drive the classifier against real engine
43
+ * errors without importing the whole database class. The loop itself lives in
44
+ * `control-retry.ts`, shared with the READ policy (`control-read-retry.ts`) — this module
45
+ * owns the write policies: their classifiers, attempt counts, backoff and budget.
46
+ */
47
+
48
+ /**
49
+ * Attempts allowed per control write: the first, plus two retries of a transiently-failed
50
+ * cluster commit. Bounded so a genuinely unreachable cohort terminates in the transactor's
51
+ * own error rather than spinning; two retries covers the observed failure (a stream still
52
+ * forming when the write fired) with margin.
53
+ */
54
+ export const CONTROL_WRITE_ATTEMPTS = 3;
55
+
56
+ /**
57
+ * Attempts allowed for {@link SCHEMA_INIT_RETRY_POLICY}, the one call site that also absorbs a
58
+ * self-coordination refusal.
59
+ *
60
+ * Two more than {@link CONTROL_WRITE_ATTEMPTS} because what this retry buys is wall-clock for a
61
+ * cold-starting node's bootstrap dials to land, and because the refusal is cheap to re-provoke
62
+ * relative to a cluster round-trip: `findCoordinator` already spends its own bounded wait before
63
+ * raising (3 attempts, 500 ms apart, `db-p2p/src/libp2p-key-network.ts` ~422) and then decides
64
+ * LOCALLY that it may not elect itself, so no peer is contacted and no cohort deadline is
65
+ * consumed.
66
+ *
67
+ * Note that the ~1 s `findCoordinator` spends internally is real, on top of this policy's own
68
+ * backoff — which is exactly why {@link CONTROL_WRITE_RETRY_BUDGET_MS} and not this count is what
69
+ * actually terminates the loop in the worst case.
70
+ */
71
+ export const SCHEMA_INIT_ATTEMPTS = 5;
72
+
73
+ /**
74
+ * Elapsed-time ceiling on the whole retry loop, measured from the START of the first
75
+ * attempt and checked after a failed attempt BEFORE sleeping.
76
+ *
77
+ * This budget — not the attempt count — is what makes the policy safe to sit under every
78
+ * control write. A transient failure (stream reset while a connection is still forming)
79
+ * surfaces in well under a second, so it is retried and the loop adds at most ~2.2 s. A
80
+ * genuinely silent cohort member costs ~21 s per consensus round (two `ClusterClient`
81
+ * response-deadline attempts, 10.5 s each at the default declared link): one pend round plus
82
+ * the cancel discharge's three rounds, about 84 s for one write in
83
+ * `control-write-degraded-cohort-member.integration.ts`, which already exceeds this budget
84
+ * when attempt 1 returns — so that case is surfaced immediately and retry adds ZERO latency
85
+ * to the case where it cannot help.
86
+ *
87
+ * It cuts the loop off by design, so it does not grow with the link: its job is to end the
88
+ * retries of slow attempts, and the caller gets the last attempt's error unchanged. An attempt
89
+ * whose read phase consulted a silent peer ends at about one per-peer read deadline
90
+ * (`cohortReadDeadlineMs` in `link-budget.ts`: 7 s at the default declared link, 5 s before it
91
+ * was derived, 1 s at Optimystic's own default), so it still gets one retry inside this budget
92
+ * where it once got two; an attempt that runs into the `ClusterClient` response deadline
93
+ * (10.5 s at the default declared link) still gets none. See `docs/cadre-consistency.md` →
94
+ * "Deadlines Over Optimystic's Reads and Commits".
95
+ *
96
+ * NOTE: a failed commit attempt also pays a cancel discharge before its error returns
97
+ * (optimystic `TransactorSource.transact`'s catch) — bounded by six rounds and
98
+ * `abortOrCancelTimeoutMs`, which every collection this repo opens sets to the larger of 5 s
99
+ * and the RPC dial deadline: 42.5 s at the default declared link (`optimysticDialLimits` in
100
+ * `link-budget.ts`;
101
+ * `../optimystic/packages/quereus-plugin-optimystic/src/optimystic-adapter/collection-factory.ts`).
102
+ * One cancel that runs its full budget therefore ends the retries on its own, cutting the
103
+ * three-attempt policy to however many attempts came before it. A cancel only runs that long
104
+ * when a peer stays silent, and that attempt has already spent its pend round past this
105
+ * budget, so the cut loses nothing there. After a transient fault the cancel is either
106
+ * answered or gives up after its six rounds against a dead transport, in about a second
107
+ * either way (optimystic `NetworkTransactor.MAX_CANCEL_ROUNDS`). Not observed — every
108
+ * measured round of the transient-reset case committed on attempt 3 of 3 — so this is a
109
+ * condition to watch, not work to do: if that case ever starts failing with
110
+ * `failed after 2/3 attempt(s)`, the cancel discharge is where the time went.
111
+ */
112
+ // eslint-disable-next-line no-restricted-syntax -- cuts off by design: it ends the retries of slow attempts rather than waiting them out; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
113
+ export const CONTROL_WRITE_RETRY_BUDGET_MS = 10_000;
114
+
115
+ /**
116
+ * Backoff before retry N, jittered ±50% (so the first sleep is 125–375 ms — the jitter can
117
+ * never floor a delay to zero). Each single sleep is additionally capped at the LARGEST
118
+ * delay in the list, so a `stop()` racing a backoff is never held up by more than ~1 s.
119
+ * There is no `AbortSignal` at this seam to cancel a sleep outright; the cap is the
120
+ * substitute, deliberately not plumbing one through every write path.
121
+ */
122
+ const CONTROL_WRITE_RETRY_DELAYS_MS: readonly number[] = [250, 1_000];
123
+
124
+ /**
125
+ * Backoff for {@link SCHEMA_INIT_RETRY_POLICY}, jittered and capped the same way. Worst case
126
+ * ~4.6 s of sleep across four retries (375 + 750 + 1500 + 2000, each capped at the largest base),
127
+ * which stays inside {@link CONTROL_WRITE_RETRY_BUDGET_MS} — the budget still terminates the loop
128
+ * first if any single attempt is itself slow.
129
+ *
130
+ * Sized against measured facts, not guessed:
131
+ *
132
+ * - the self-coordination guard clears the moment the node has ONE connection again (both
133
+ * branches that raise `grace-period-not-elapsed` require `getConnections().length === 0`), not
134
+ * when the 30 s grace period expires — so this does NOT need to cover the grace period, only
135
+ * the gap until a bootstrap dial completes;
136
+ * - the guard's 30 s `gracePeriodMs` cannot be tuned from this repo anyway: no caller in either
137
+ * repo passes a `SelfCoordinationConfig`, so the default is always in force;
138
+ * - a node that has NEVER connected is waved through as a bootstrap node (network high-water
139
+ * mark of 1), so this retry only ever delays a node that HAS seen peers. One whose peers are
140
+ * gone for good still fails, ~4.6 s later — which is why the budget stays at the shared
141
+ * ceiling rather than stretching to cover the full 30 s window.
142
+ */
143
+ const SCHEMA_INIT_RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000];
144
+
145
+ /** The network transactor's aggregate, raised when some cohort peers gave no usable answer. */
146
+ const TRANSACTOR_AGGREGATE = /Some peers did not complete:/;
147
+
148
+ /**
149
+ * How the aggregate's per-batch details name a SINGLE-block batch — the shape both the block
150
+ * read (`get`) and phase 1 (`pend`) format, and the only shape safe to re-present.
151
+ */
152
+ const SINGLE_BLOCK_BATCH_TOKEN = '[block:';
153
+
154
+ /**
155
+ * How the aggregate's per-batch details name a MULTI-block batch — the shape phase 2
156
+ * (`commitBlocks`) formats, and the marker of an outcome too indeterminate to retry.
157
+ */
158
+ const COMMIT_BATCH_TOKEN = '[blocks:';
159
+
160
+ /**
161
+ * The cluster coordinator's super-majority shortfall (`db-p2p/src/repo/cluster-coordinator.ts`),
162
+ * matched ONLY with a ZERO rejection count. The same message with a non-zero count means a
163
+ * member actually voted no (that branch carries `membership-not-admitted` rejections), and
164
+ * retrying it would re-present a spent signature to a cohort that already refused it. This one
165
+ * is raised while collecting PROMISES, before any commit, so re-presenting it is safe.
166
+ *
167
+ * This regex itself never matches a decisive rejection (`ValidatorRejectionError`, `Transaction
168
+ * rejected by validators`) — that text carries no `Failed to get super-majority:` prefix. But the
169
+ * composite classifier is not this matcher alone: a rejection raised while promises are still
170
+ * being collected reaches this repo wrapped in the transactor's `Some peers did not complete:
171
+ * …[block:…]` aggregate, and {@link isUncommittedTransactorAggregate} matches that wrapper on its
172
+ * own, whatever the cause inside it says — see the accepted-tradeoff `NOTE:` there.
173
+ */
174
+ const SUPER_MAJORITY_SHORTFALL_UNANSWERED =
175
+ /Failed to get super-majority: \d+\/\d+ approvals \(needed \d+, 0 rejections\)/;
176
+
177
+ /**
178
+ * Is this a transactor aggregate from a phase where nothing can have committed yet?
179
+ *
180
+ * The transactor raises `Some peers did not complete:` from three sites
181
+ * (`db-core/src/transactor/network-transactor.ts`), and only two of them are safe to
182
+ * re-present:
183
+ *
184
+ * - `get` (a block read, which a write body also performs) and `pend` (phase 1) fail before
185
+ * anything commits, so the write is known not to have landed;
186
+ * - `commitBlocks` (phase 2) is REACHABLE here and NOT safe. `commitBlock` throws the
187
+ * aggregate when a header/tail commit got NO response at all — precisely the transient
188
+ * class this retry targets — and that escapes `commit()`, survives `TransactorSource`'s
189
+ * cancel-and-rethrow (cancel reaches PENDING actions only; a peer that already committed
190
+ * stays committed) and `Collection.syncInternal` (which retries StaleFailure RETURN values,
191
+ * not throws), reaching this funnel QuereusError-wrapped. The tail commit is one batch to
192
+ * one coordinator running consensus internally, so a no-response there is INDETERMINATE:
193
+ * the commit may have completed with only the response lost. Re-running the write body over
194
+ * a write that landed turns a success into a constraint failure (e.g.
195
+ * `UNIQUE constraint failed: CadrePeer.PeerId`). Surfacing the transient error instead lets
196
+ * the caller re-read committed state and decide. Commit-phase messages are vetoed by
197
+ * {@link reportsIndeterminateCommit}, not here.
198
+ *
199
+ * The `cause` chain does not discriminate the phases — a commit-phase aggregate carries the
200
+ * same stream-reset/dial error a pend-phase one does. The per-batch DETAIL text does: `get`
201
+ * and `pend` format `<peerId>[block:<id>](<status>)`, `commitBlocks` formats
202
+ * `<peerId>[blocks:<count>](<status>)`. `[block:` cannot occur inside `[blocks:`, so the two
203
+ * tokens are disjoint and separate the phases.
204
+ *
205
+ * NOTE: the discriminator is a formatting detail of another repo. If Optimystic ever reformats
206
+ * those per-batch details, this fails CLOSED — the aggregate stops matching and control writes
207
+ * simply stop being retried, silently losing the absorption rather than doing anything unsafe.
208
+ * The guard against that is live: `control-write-degraded-cohort-member.integration.ts` asserts
209
+ * this classifier against the real degraded-cohort failure object and asserts the `[block:`
210
+ * token on the live aggregate; if it reddens there, an upstream reformat is the first thing to
211
+ * check.
212
+ *
213
+ * NOTE: the discriminator is the prefix AND the token within ONE message — never relax it to
214
+ * "contains `[block:`". Beyond the `get` and `pend` sites above, Optimystic renders `[block:` in
215
+ * one further place — a third formatter, outside the aggregate's three sites — `dischargeCancel`
216
+ * (`Cancel of action <id> did not discharge <n> block(s): …`), which does not raise the
217
+ * `Some peers did not complete:` prefix; only the conjunction keeps a cancel fault from
218
+ * classifying as a retriable get/pend (checked against optimystic `c56c2bd4`, 2026-09-16).
219
+ *
220
+ * NOTE: accepted tradeoff — a REJECTION raised while promises are still being collected reaches
221
+ * this repo wrapped in this same aggregate, and this matcher claims it on the wrapper alone,
222
+ * whatever the cause inside says. That re-presents such a rejection up to two more times inside
223
+ * the 10 s budget. Kept deliberately, and re-decided on 2026-09-17 against the current upstream
224
+ * behaviour rather than inherited:
225
+ *
226
+ * - Contention is no longer a rejection at all. "Another write holds this block right now"
227
+ * (`pending conflict`) is now a `held` verdict that counts toward neither approvals nor
228
+ * rejections (`validatePendOperations`,
229
+ * `../optimystic/packages/db-p2p/src/cluster/cluster-repo.ts`), so the rejection this tradeoff
230
+ * was originally written about does not arrive any more. It reaches this funnel as
231
+ * `SyncRetryExhaustedError` instead, which {@link reportsPossiblyStoredWrite} declines by type
232
+ * — upstream documents resubmitting after it as unsafe (the attempt that spent the budget may
233
+ * have left its log entry standing), and its collection sync has already spent ten retries on
234
+ * the race. The torn-write rule beside it: a `TornActionError` is re-presented only when
235
+ * `final` is true; any other torn write is declined.
236
+ * - What remains rejectable at the promise phase is stale revision, block-unavailable,
237
+ * membership-not-admitted, and a configured validator's refusal. Only the first is worth a
238
+ * retry — and it is worth one: this loop re-runs the WHOLE write body, reads included
239
+ * (`ControlDatabase.withWriteLock`'s contract), so attempt 2 presents a pend against the
240
+ * revision that made attempt 1 stale. A blanket "decline any chain reporting a validator
241
+ * rejection" rule would remove that retry to save two wasted attempts on the other three.
242
+ *
243
+ * Revisit if a re-presentation ever becomes expensive relative to what it buys — a rejection class
244
+ * that costs a full cohort round-trip to re-present, or a write body whose reads are no longer
245
+ * cheap — or when upstream offers a typed refusal surface to classify on instead of this text
246
+ * (`../optimystic/tickets/backlog/debt-a-downstream-repo-classifies-retries-by-parsing-our-error-text`).
247
+ * Contention is no longer the reason to revisit.
248
+ *
249
+ * An aggregate whose details came out EMPTY (possible when `formatBatchStatuses` has no
250
+ * batches to format) carries neither token, matches nothing here, and is not retried — an
251
+ * unattributable failure is not a proven non-commit.
252
+ *
253
+ * Exported for the READ classifier (`control-read-retry.ts`), which reuses it verbatim: a
254
+ * `get` batch formats the same single-block `[block:` token, and a read can never produce
255
+ * the commit-phase `[blocks:` shape at all.
256
+ */
257
+ export function isUncommittedTransactorAggregate(message: string): boolean {
258
+ return TRANSACTOR_AGGREGATE.test(message) && message.includes(SINGLE_BLOCK_BATCH_TOKEN);
259
+ }
260
+
261
+ /**
262
+ * Does ANY message in the failure's `cause` chain describe a commit-phase batch — i.e. an
263
+ * outcome nobody can call committed or not?
264
+ *
265
+ * Vetoes the whole chain rather than the one message carrying the token, and vetoes it
266
+ * regardless of which matcher would otherwise have claimed it. The narrower per-message rule
267
+ * would already catch every shape the transactor is known to build (a commit-phase aggregate
268
+ * emits `[blocks:` for every batch it formats, and embeds its cause's message inline as
269
+ * `root: …`, so both tokens land in one string) — but "known to build" is an argument about
270
+ * another repo's error assembly, and this way an indeterminate commit anywhere in the chain
271
+ * costs a retry we could have had instead of risking a re-run over a write that landed.
272
+ */
273
+ function reportsIndeterminateCommit(messages: readonly string[]): boolean {
274
+ return messages.some(message => message.includes(COMMIT_BATCH_TOKEN));
275
+ }
276
+
277
+ /**
278
+ * Does any link of the `cause` chain say some of this write may be stored, or may still be?
279
+ * Such a failure is never re-presented — a re-run could store the write twice.
280
+ *
281
+ * - `SyncRetryExhaustedError` (and its `SyncRevisionStalledError` subclass): upstream documents
282
+ * resubmitting as unsafe, because the attempt that spent the budget may have left its log
283
+ * entry standing.
284
+ * - `TornActionError` without `final: true`: the write's log entry is stored and nobody could
285
+ * establish that the rest can never land — it may be saved already, or land later.
286
+ * - `CoordinatorPartialCommitError` / `PartialCommitError`: another collection or tree of the
287
+ * same transaction is already durable, and re-running the whole body would apply it twice.
288
+ *
289
+ * Every one of these embeds text from elsewhere in its message (a responder's refusal, the
290
+ * underlying failure), which can carry the pend-phase aggregate's prefix and `[block:` token —
291
+ * so this veto runs before any text matcher.
292
+ *
293
+ * Matched by TYPE: each survives the bridge's and Quereus' rewraps on `cause`. An error built by a
294
+ * second loaded copy of `@optimystic/db-core` (or of the plugin, for `PartialCommitError`) fails
295
+ * `instanceof`, and then this veto does not fire — falling back to the text classifiers, which is
296
+ * how these failures were classified before the veto existed. The asymmetry with
297
+ * {@link isFinalTornWrite} is deliberate: there, a missed `instanceof` means no retry, which is the
298
+ * safe side. No text fallback parses `TornActionError`'s closing sentence, since upstream says its
299
+ * wording is for log lines only.
300
+ *
301
+ * NOTE: the plugin is registered through its `/plugin` entry (`control-database.ts`) but
302
+ * `PartialCommitError` is imported from its root entry; the two are one class only because the
303
+ * plugin's build emits both entries over one shared chunk. If upstream ever bundles the entries
304
+ * separately, this `instanceof` stops matching with every spec still green — import the class from
305
+ * the entry the plugin is registered through.
306
+ */
307
+ function reportsPossiblyStoredWrite(links: readonly Error[]): boolean {
308
+ return links.some(link =>
309
+ link instanceof SyncRetryExhaustedError
310
+ || (link instanceof TornActionError && link.final !== true)
311
+ || link instanceof CoordinatorPartialCommitError
312
+ || link instanceof PartialCommitError);
313
+ }
314
+
315
+ /**
316
+ * A torn write the library marks FINAL: not saved, unable to land, its pending records confirmed
317
+ * cancelled — so submitting it again stores it once.
318
+ *
319
+ * Worth re-presenting, not merely safe to: upstream raises it mostly when a rival holds the
320
+ * revision this write's log entry claimed, which is contention, and the loop re-runs the write
321
+ * body's reads, so the next attempt builds on the rival's revision — the same argument that keeps
322
+ * stale-revision rejections retried (the accepted-tradeoff `NOTE:` on
323
+ * {@link isUncommittedTransactorAggregate}).
324
+ *
325
+ * NOTE: `CoordinatorStaleLossError` (db-core; "nothing durably committed, safe to re-drive") is not
326
+ * claimed, so it falls to the text matchers; it escapes only after the coordinator's own retry budget
327
+ * ran out. Seen abandoning a control write on 2026-09-18 (`control-delete-while-alone-convergence`,
328
+ * under load): a node's own storage refused its own pend as a `stale conflict` for the ~14 s the
329
+ * coordinator spent re-driving, then gave up. Still not claimed here — the failing attempt already
330
+ * ran past {@link CONTROL_WRITE_RETRY_BUDGET_MS}, so a retry from this loop would never get to run,
331
+ * and the refusal came from the node's OWN storage disagreeing with its own revision view, which does
332
+ * not change while the node is alone; re-driving the same write body again would hit the same
333
+ * refusal. The cause (a commit torn by a sibling that stopped mid-commit, leaving the node with a
334
+ * revision view its own storage disputes) is `tickets/blocked/forked-control-collection-sync-livelocks.md`
335
+ * → "Second trigger".
336
+ */
337
+ function isFinalTornWrite(link: Error): boolean {
338
+ return link instanceof TornActionError && link.final === true;
339
+ }
340
+
341
+ /** The coordinator's shortfall, raised pre-commit, with nobody having voted no. */
342
+ function isUnansweredSuperMajorityShortfall(message: string): boolean {
343
+ return SUPER_MAJORITY_SHORTFALL_UNANSWERED.test(message);
344
+ }
345
+
346
+ /**
347
+ * Optimystic refusing to let a node elect ITSELF coordinator because its last connection dropped
348
+ * less than the guard's grace period ago (`Libp2pKeyPeerNetwork.shouldAllowSelfCoordination` →
349
+ * `findCoordinator`, `db-p2p/src/libp2p-key-network.ts`).
350
+ *
351
+ * Matched ONLY with the `grace-period-not-elapsed` reason. The same sentence is raised with three
352
+ * other reasons and none of them is worth a retry: `disabled` is static configuration, and
353
+ * `partition-detected` / `suspicious-shrinkage` describe a network condition that will not resolve
354
+ * inside a few seconds of backoff. The grace-period one WILL — it clears as soon as the node holds
355
+ * one connection again, which a cold-starting node dialling its bootstrap peers is actively
356
+ * working on.
357
+ *
358
+ * NOTE: matched by TEXT, not by type. `FindCoordinatorError` (`code: 'SELF_COORDINATION_BLOCKED'`)
359
+ * is exported from `@optimystic/db-p2p`, and when this matcher was written the error OBJECT did not
360
+ * survive the trip: `OptimysticVirtualTable.initialize` rethrew as `new Error(message)` with no
361
+ * `cause`. It now rethrows through `rewrapAsQueryError`, which keeps `cause`
362
+ * (`quereus-plugin-optimystic/src/optimystic-module.ts`); whether every Quereus wrapper above it on
363
+ * the schema-init path keeps it too has not been checked, and the text still arrives either way.
364
+ * Like every matcher here this fails CLOSED — a rewording upstream stops the retry engaging, it
365
+ * never makes it unsafe.
366
+ */
367
+ const SELF_COORDINATION_GRACE_REFUSAL =
368
+ /Self-coordination blocked: grace-period-not-elapsed\. No coordinator available for key\./;
369
+
370
+ /** Self-coordination refused because the node's last connection dropped moments ago. */
371
+ function isSelfCoordinationGraceRefusal(message: string): boolean {
372
+ return SELF_COORDINATION_GRACE_REFUSAL.test(message);
373
+ }
374
+
375
+ /**
376
+ * The error messages that mean "the cohort did not answer AND nothing committed", and nothing
377
+ * else. The observed one-shot failure was `registerSelf()` racing a connection still forming:
378
+ * a read/pend-phase transactor aggregate with `cause=The stream has been reset`.
379
+ *
380
+ * Deliberately NOT matched: every constraint / authorization failure
381
+ * (`CHECK constraint failed:`, `UNIQUE constraint failed:`) — those reach the same funnel
382
+ * and must be retried zero times: the cohort REFUSED the write, so re-presenting it
383
+ * re-presents a spent signature and can never commit.
384
+ */
385
+ const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[] = [
386
+ isUncommittedTransactorAggregate,
387
+ isUnansweredSuperMajorityShortfall,
388
+ ];
389
+
390
+ /**
391
+ * The above PLUS the self-coordination grace refusal — the classifier for schema init ONLY
392
+ * ({@link SCHEMA_INIT_RETRY_POLICY}), deliberately not folded into the list above.
393
+ *
394
+ * Why this class is safe HERE and not everywhere: a write refused at coordinator SELECTION never
395
+ * reached a peer, so re-presenting it is a proven non-commit. But `findCoordinator` is also
396
+ * reached from `NetworkTransactor.commitBlock` during PHASE 2 (`resolveCoordinator`,
397
+ * `db-core/src/transactor/network-transactor.ts`), and `commit()` commits the header block before
398
+ * the rest — so a general control write refused there can be refused AFTER something already
399
+ * committed. Worse, that error carries no `[blocks:` batch token, so
400
+ * {@link reportsIndeterminateCommit} would not veto it, and re-running an insert body over a write
401
+ * that landed is exactly the `UNIQUE constraint failed: CadrePeer.PeerId` failure that veto exists
402
+ * to prevent.
403
+ *
404
+ * Schema init does not have that problem: `apply schema` is a diff rather than a replay, and a
405
+ * failed apply is taken back whole — Quereus unwinds every step it ran and verifies the catalog
406
+ * against a pre-apply fingerprint — so a re-run emits exactly the DDL the live catalog is missing.
407
+ * The full argument, including the plugin-side dependency that unwind carries, is at the
408
+ * `applyControlSchema` call site in `control-database.ts`.
409
+ *
410
+ * RETRIED DELIBERATELY, not by oversight: the failures Quereus cannot take back. When the unwind
411
+ * does not complete — an undo statement threw, the post-unwind catalog did not match the pre-apply
412
+ * fingerprint, that catalog could not be re-collected to check, or a step the differ marks
413
+ * irreversible because it discards data (dropping a table or a column, narrowing a column's type —
414
+ * none of which reaches a `CadreControl` apply: a cold store's diff is all creates, and
415
+ * `refuseIncompatibleControlStore` in `control-schema-version.ts` refuses a warm store whose diff
416
+ * holds anything but creates and tag changes before the apply runs) poisoned the journal before it
417
+ * ran, leaving nothing unwound — Quereus keeps the failing step's own message, APPENDS the reason the
418
+ * schema could not be restored, and carries the original as `cause`. So a transient cause
419
+ * underneath still matches here, and neither veto ({@link reportsPossiblyStoredWrite},
420
+ * {@link reportsIndeterminateCommit}) looks for that reason text. Retrying over a partially migrated
421
+ * schema is safe for the same reason the ordinary re-run is: the plugin commits whatever its write
422
+ * batch holds, so storage and the catalog are left in step either way, and the next apply diffs
423
+ * against what is really there. Vetoing it instead would turn a healable transient outage into a
424
+ * dead start.
425
+ */
426
+ const RETRIABLE_SCHEMA_INIT_MATCHERS: readonly ((message: string) => boolean)[] = [
427
+ ...RETRIABLE_CONTROL_WRITE_MATCHERS,
428
+ isSelfCoordinationGraceRefusal,
429
+ ];
430
+
431
+ /**
432
+ * Did this control write fail because the cluster cohort did not ANSWER — i.e. is
433
+ * re-presenting the SAME signed write a moment later the right response?
434
+ *
435
+ * Classifies the transient cases by MESSAGE, walking the `cause` chain with the shared
436
+ * {@link chainMessages} (Quereus' own `unwrapError` underneath) — the transactor and the
437
+ * coordinator both throw bare `Error`s, not recognisable by type. By the time one reaches
438
+ * `ControlDatabase` it is wrapped in a `QuereusError` (the real chain is `QuereusError` → `Error`
439
+ * → `Error`), so the match must work at any depth. Anything that is not an `Error` is never
440
+ * retried.
441
+ *
442
+ * Two vetoes run over the whole chain before any matcher: {@link reportsPossiblyStoredWrite}
443
+ * (by type — a spent sync budget, a non-final torn write, a partial commit) and
444
+ * {@link reportsIndeterminateCommit} (by text — a commit-phase batch). A chain either veto flags,
445
+ * at any level, is never retried on the strength of some other level looking transient. The one
446
+ * typed failure claimed as retriable is a final torn write ({@link isFinalTornWrite}).
447
+ *
448
+ * NOTE: this depends on engine/transactor error TEXT. The messages producible without a
449
+ * network are driven from the REAL engine in `control-formation-seat-budget.spec.ts`, so
450
+ * a rewording reddens a spec rather than silently disabling the retry. The two RETRIABLE
451
+ * messages need a real multi-node cluster to produce; the degraded-cohort scenario
452
+ * (`control-write-degraded-cohort-member.integration.ts`) asserts this classifier against
453
+ * both LIVE — the super-majority shortfall from a real silent member, and the transactor
454
+ * aggregate from a real stream reset, which the retry there absorbs end to end.
455
+ */
456
+ export function isRetriableControlWriteFailure(error: unknown): boolean {
457
+ return matchesRetriableFailure(error, RETRIABLE_CONTROL_WRITE_MATCHERS);
458
+ }
459
+
460
+ /**
461
+ * {@link isRetriableControlWriteFailure} widened by exactly one class — the self-coordination
462
+ * grace refusal ({@link RETRIABLE_SCHEMA_INIT_MATCHERS}) — for `ControlDatabase.applyControlSchema`.
463
+ *
464
+ * Opt-in per call site, via {@link SCHEMA_INIT_RETRY_POLICY}. Every other control write keeps the
465
+ * default classifier untouched.
466
+ */
467
+ export function isRetriableSchemaInitFailure(error: unknown): boolean {
468
+ return matchesRetriableFailure(error, RETRIABLE_SCHEMA_INIT_MATCHERS);
469
+ }
470
+
471
+ /**
472
+ * Shared body of both classifiers: walk the `cause` chain, let {@link reportsPossiblyStoredWrite}
473
+ * and then {@link reportsIndeterminateCommit} veto it, then claim a final torn write
474
+ * ({@link isFinalTornWrite}), then ask `matchers`. The vetoes run BEFORE any matcher for every
475
+ * policy — a chain reporting a possibly-stored write or a commit-phase batch is never retried,
476
+ * however transient some other level looks, and a final torn write carried by a partial commit is
477
+ * still declined because a sibling collection landed.
478
+ */
479
+ function matchesRetriableFailure(
480
+ error: unknown,
481
+ matchers: readonly ((message: string) => boolean)[]
482
+ ): boolean {
483
+ if (!(error instanceof Error)) {
484
+ return false;
485
+ }
486
+ const links = causeChain(error);
487
+ if (reportsPossiblyStoredWrite(links)) {
488
+ return false;
489
+ }
490
+ const messages = chainMessages(error);
491
+ if (reportsIndeterminateCommit(messages)) {
492
+ return false;
493
+ }
494
+ if (links.some(isFinalTornWrite)) {
495
+ return true;
496
+ }
497
+ return messages.some(message => matchers.some(matches => matches(message)));
498
+ }
499
+
500
+ /**
501
+ * Policy and pacing overrides for {@link retryControlWrite}. Production callers pass nothing
502
+ * (or a named policy such as {@link SCHEMA_INIT_RETRY_POLICY}); specs inject a recorded
503
+ * `sleep`, a shorter delay list, or a fake clock so no test ever waits out a real backoff.
504
+ *
505
+ * Every field defaults to the shipped control-write policy ({@link CONTROL_WRITE_ATTEMPTS},
506
+ * {@link CONTROL_WRITE_RETRY_DELAYS_MS}, {@link isRetriableControlWriteFailure}), so an
507
+ * omitted field is byte-identical to the behaviour before per-call-site policies existed.
508
+ * The field shapes and semantics live on the shared {@link ControlRetryOptions}.
509
+ */
510
+ export type ControlWriteRetryOptions = ControlRetryOptions;
511
+
512
+ /**
513
+ * The one non-default retry policy: `ControlDatabase.applyControlSchema`'s distributed DDL, which
514
+ * additionally absorbs optimystic's self-coordination grace refusal and gets more attempts over a
515
+ * longer backoff to do it (see {@link SCHEMA_INIT_ATTEMPTS} and
516
+ * {@link SCHEMA_INIT_RETRY_DELAYS_MS}). Still bounded by the shared
517
+ * {@link CONTROL_WRITE_RETRY_BUDGET_MS}.
518
+ *
519
+ * NOTE: exactly one call site opts in today, and the re-run safety this policy assumes is that
520
+ * site's, not a general property — `apply schema` is a diff, and a failed apply is unwound whole
521
+ * and verified against the pre-apply catalog (full argument at the `applyControlSchema` call site
522
+ * in `control-database.ts`). A second opt-in must re-derive that argument for its own write body
523
+ * first; if this policy ever grows a third consumer, rename it for what the callers share rather
524
+ * than widening it by default.
525
+ */
526
+ export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
527
+ attempts: SCHEMA_INIT_ATTEMPTS,
528
+ delaysMs: SCHEMA_INIT_RETRY_DELAYS_MS,
529
+ isRetriable: isRetriableSchemaInitFailure,
530
+ };
531
+
532
+ /**
533
+ * Run `attempt` up to {@link CONTROL_WRITE_ATTEMPTS} times (or `options.attempts`), retrying only
534
+ * failures the policy's classifier — {@link isRetriableControlWriteFailure} by default —
535
+ * calls transient, and only while {@link CONTROL_WRITE_RETRY_BUDGET_MS} has not elapsed.
536
+ *
537
+ * `attempt` must be atomic and re-runnable: a failed cluster write rolls back (nothing is
538
+ * half-applied), and re-running the body re-runs its reads too — `ControlDatabase`'s
539
+ * locked write bodies all satisfy this (see the contract on
540
+ * `ControlDatabase.withWriteLock`). The backoff sleeps happen with NO lock held; the caller
541
+ * takes and releases its lock inside `attempt`.
542
+ *
543
+ * On exhaustion (attempts or budget) the LAST error is rethrown unchanged — never wrapped,
544
+ * so the exact messages downstream code and the degraded-cohort-member scenario assert on
545
+ * survive. A non-retriable failure propagates from the attempt that raised it.
546
+ *
547
+ * Either way — declined or exhausted — an `options.onAbandon` observer is notified exactly
548
+ * once before the rethrow (a `ControlRetryAbandonment`). Only WRITES pass one: an abandoned
549
+ * read throws to a caller that is awaiting it, so nothing is lost silently there
550
+ * (`control-read-retry.ts` says so at its own seam), while an abandoned write may have no
551
+ * caller at all — a background self-record republish is `void`-ed with a `debug` catch, so
552
+ * its failure reaches nobody unless something is listening here.
553
+ * `ControlDatabase.setControlWriteAbandonedListener` is the only thing that passes one in
554
+ * production; `retryControlWrite` itself neither supplies nor requires an observer.
555
+ */
556
+ export function retryControlWrite<T>(
557
+ attempt: () => Promise<T>,
558
+ options: ControlWriteRetryOptions = {}
559
+ ): Promise<T> {
560
+ return retryControlOperation(attempt, {
561
+ attempts: CONTROL_WRITE_ATTEMPTS,
562
+ delaysMs: CONTROL_WRITE_RETRY_DELAYS_MS,
563
+ budgetMs: CONTROL_WRITE_RETRY_BUDGET_MS,
564
+ isRetriable: isRetriableControlWriteFailure,
565
+ // The default prefix, named anyway: the degraded-cohort scenario asserts on
566
+ // `Control write [<label>] …` lines byte-for-byte.
567
+ logPrefix: 'Control write',
568
+ }, options);
569
+ }