@serfab/quereus-plugin-sereus 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,621 +1,621 @@
1
- import type { NodeOptions } from '@optimystic/db-p2p';
2
-
3
- /**
4
- * Smallest cluster size Optimystic will honour — its own `minAbsoluteClusterSize`,
5
- * and the smallest value that reaches the cluster path at all (a lone node writes to
6
- * local storage without forming a cluster). A validation floor, not a default.
7
- */
8
- export const MIN_CLUSTER_SIZE = 2;
9
-
10
- /**
11
- * How long ONE cohort peer gets to answer ONE read-path request, in milliseconds — both
12
- * networks' `clusterPolicy.cohortQueryTimeoutMs`. It sets the latest-revision query a read
13
- * runs when its local copy may be stale (`CoordinatorRepo.queryClusterForLatest`) and the
14
- * archive fetch that follows it (`fetchArchiveFromPeer`); Optimystic ties the two to one
15
- * field because they are the same round trip to the same peer. Optimystic's own default is
16
- * 1000 ms, a LAN-shaped budget, and it deliberately left that default alone — choosing the
17
- * value for a deployment of phones is the embedder's job.
18
- *
19
- * **Why 7000: it is a derivation, written out.** One read-path request opens a fresh protocol
20
- * stream over a connection that is already open, which costs the protocol negotiation plus the
21
- * request and its answer: two link round trips, `CIRCUIT_REQUEST_ROUND_TRIPS` in cadre-core's
22
- * `link-budget.ts`. At the link round trip cadre declares by default,
23
- * `DECLARED_LINK_ROUND_TRIP_MS` (3 500 ms, covering the 3-second relayed round trip sereus
24
- * supports), that is 2 x 3 500 = 7 000 ms — `cohortReadDeadlineMs()` in that module. This
25
- * constant must equal it, and `cadre-core/test/link-budget.spec.ts` pins the two equal; it is
26
- * spelled as a number here only because this package cannot import cadre-core (the dependency
27
- * runs the other way). A host that declares its own link (`NetworkConfig.linkRoundTripMs`)
28
- * gets the derivation at that link instead of this constant, and a host that sets
29
- * `NetworkConfig.cohortQueryTimeoutMs` gets exactly what it set. No admission allowance is
30
- * added: a read runs over an open connection and opens none. The whole-pass reconcile bound
31
- * Optimystic derives from it (`max(5000, 5 x this)`) becomes 35 s.
32
- *
33
- * **History: why 5000 before, and why it was not enough.** Optimystic GitHub #22, reported by
34
- * a sereus-based chat app: two parties that reach each other only through a public circuit
35
- * relay have a round trip near 1.8 s, and a fresh stream (dial or reuse, protocol select, send,
36
- * receive) cannot finish inside a second. At a cohort of two there is exactly one peer to ask,
37
- * so one late answer leaves the read with nothing to corroborate against and it is declined.
38
- * 5000 was chosen to cover 1.8 s with room for the Noise handshake, the relay hops and a
39
- * phone's pure-JS crypto (cadre-core's `NetworkConfig.noiseCrypto`), and it is the value
40
- * Optimystic's own note advises for that link. But sereus supports a 3 s round trip, and two
41
- * round trips of pure delay at that link is 6 s: above 5 000, so at the supported link every
42
- * consult timed out and every read had nothing to corroborate against — the #22 failure again,
43
- * one band up. The derivation ends that class: the next person to widen the link changes one
44
- * declaration.
45
- *
46
- * **Measured at 5000**, 2026-09-26, one Windows developer machine, two relay-only `CadreNode`s
47
- * on one shared loopback dedicated relay, the one-way outbound frame delay raised to 900 ms
48
- * (`pipelined` mode — see `docs/testing.md` → "Where measurements live") AFTER the strand had
49
- * formed and first synced, for a round trip near 1.8 s. A machine that had joined, gone away
50
- * while the other wrote, then re-attached over its own stale store and read the row written
51
- * while it was away. Declined reads over that journey, after the delay was raised: **18 at
52
- * Optimystic's 1000 ms, 2 at 5000 ms.** A declined read is
53
- * `cluster-fetch:peers-silent { silent: 1, consulted: 2 }` followed by
54
- * `cluster-fetch:no-quorum`, retried about a second later until one lands; at 5000 the
55
- * corroboration succeeded instead (`cluster-fetch:local-current`). Both journeys COMPLETED at
56
- * either value, which is why no committed scenario gates this — 900 ms is not where it breaks.
57
- * The recipe for re-measuring is in `docs/testing.md` → "Where measurements live".
58
- *
59
- * **Measured at 7000 against 5000**, 2026-09-29, same machine and shape, at 1 500 ms one-way
60
- * (the supported 3 s round trip), fresh-join arm, one run each under
61
- * `DEBUG=optimystic:db-p2p:coordinator-repo*`, on `@optimystic/*` 1.7.0: **39 declined reads at
62
- * 5000, 32 at 7000**, both runs completing (writable at 70.2 s and 63.6 s). That difference
63
- * was noise, not the deadline at work: in both runs every consult on the joiner ended
64
- * 3.00-3.02 s after the one before it, the signature of Optimystic's then-fixed 3 000 ms
65
- * request dial deadline, which also bounded the protocol negotiation on an ALREADY-OPEN
66
- * connection, where the negotiation alone costs one link round trip — 3 s at this delay.
67
- *
68
- * **Measured at 7000 on 1.8.0**, the same day and shape, once a consult ran under this deadline
69
- * alone and cadre-core stated its declared link to Optimystic: **no declined read** during the
70
- * measured attach, over two fresh joins and three re-attaches, and every consult on the joiner
71
- * completed 6.03-6.05 s after the one before it — the two round trips this deadline is derived
72
- * from, with about 0.95 s to spare. The first-sync figures from those runs are on
73
- * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`.
74
- *
75
- * **What it costs**, in the terms Optimystic's own field doc uses: a peer that is truly gone
76
- * now holds a read of a block missing locally for up to 7 s before the read is declined
77
- * instead of 5 s (1 s at Optimystic's default). A joining machine's first sync runs several
78
- * such consults, so it is the cost that binds — and the first-sync band measured at each
79
- * deadline, against the budget it has to fit, is recorded once, on
80
- * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` in cadre-core's `strand-first-sync-gate.ts`. At 5000
81
- * against the 30 s budget that once preceded it, that change alone would have turned a
82
- * slow-but-working join into the very `StrandAwaitingFirstSyncError` the report named.
83
- *
84
- * **What else it lengthens.** Every cadre-core deadline that waits on an Optimystic read or
85
- * commit waits on this one too; which of them cut the read off on purpose and which must
86
- * outlast it is listed in `docs/cadre-consistency.md` → "Deadlines Over Optimystic's Reads and
87
- * Commits".
88
- *
89
- * **One value for both networks, not two.** The reason to widen is the link, and a phone's
90
- * control node and its strand nodes share it; nothing about control traffic or strand traffic
91
- * argues for different numbers. A host that needs another value moves both at once —
92
- * cadre-core's `NetworkConfig.cohortQueryTimeoutMs` or `NetworkConfig.linkRoundTripMs`,
93
- * threaded to {@link controlClusterPolicy} and {@link strandClusterPolicy} through one helper
94
- * (`declaredCohortReadDeadlineMs` in cadre-core's `link-budget.ts`).
95
- *
96
- * NOTE: this number and the first-sync budget are coupled; the margin between that budget and
97
- * the slowest first sync measured is stated on it. Re-measure the first-sync band before
98
- * raising the declared link round trip, or when a deployment's first sync grows more
99
- * collections than the two-table scenario measured above — `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`
100
- * carries the result and `docs/testing.md` the recipe.
101
- * Nothing warns when the margin goes; the symptom is `StrandAwaitingFirstSyncError` on a join
102
- * that was progressing normally, which is the failure this pair of numbers exists to end.
103
- */
104
- export const COHORT_READ_DEADLINE_MS = 7000;
105
-
106
- /**
107
- * How many nodes each block of the **control** (cadre membership) database is replicated
108
- * to. Chosen to exceed any party's node count, so in practice every control block lands
109
- * on every member of the party. Cohort width alone only delivers that for blocks written
110
- * while the members were serving the network: a block committed while the writer was
111
- * alone (the founder's genesis above all — collection headers are written once and never
112
- * revised) has a cohort of one, and it reaches later joiners through cadre-core's
113
- * peer-join block catch-up (`peer-join-backfill.ts`, membership-gated on the control
114
- * network), not through this breadth.
115
- *
116
- * **Why full replication here.** Every control node reads the whole control database —
117
- * membership, peer addresses, the strand list — so a member left out of a block's cohort is
118
- * a member that may never learn the fact, and at a cohort of two the *read repair* that is
119
- * supposed to make that safe cannot converge (it can ask exactly one peer, and that peer may
120
- * be the member that also missed the write). Measured: 4 failures in 10 runs of the
121
- * control-DB replication scenario at size 2, 0 in 20 at size 3 and 0 in 10 at size 8.
122
- *
123
- * **Why this number, and why a constant.** Optimystic caps a cohort at the peers that
124
- * actually serve the network and downsizes a cohort it cannot fill (`allowDownsize: true`,
125
- * which Cadre passes), so any value at or above the party's node count yields the same
126
- * behaviour: cohort = whole party. 16 is roughly twice the largest deployment the product
127
- * documents (`docs/architecture.md` → "Enterprise (Multi-Node Mixed)", 7 nodes) — headroom
128
- * without pretending to support arbitrarily large parties; a party genuinely running more
129
- * than 16 nodes is back to partial replication for its control database and should raise
130
- * this. It is a constant rather than the live member count because Optimystic freezes the
131
- * value at libp2p-node construction, before the `ControlDatabase` holding the `CadrePeer`
132
- * rows exists, and because a per-node derivation lets two members disagree.
133
- *
134
- * A cadre is one party by construction, so the owner-blind cohort-selection question that
135
- * applies to strand networks (see {@link DEFAULT_STRAND_CLUSTER_SIZE} below) is not a question
136
- * here: "replicate to the whole party" already is the whole intent, and there is no party
137
- * diversity for this breadth to buy or fail to buy.
138
- *
139
- * Canonical explanation of all of the above — the read-repair mechanics, what whole-party
140
- * breadth does and does not remove, and what the wider cohort costs in write availability —
141
- * is `docs/architecture.md` → "Replication cluster size". Keep it there, not here.
142
- *
143
- * **Deliberately not `clusterPolicy.assumedClusterSize`.** That option means "the smallest
144
- * cohort this deployment can genuinely field", and it feeds both the membership admission
145
- * gate's low-confidence path *and* the read-repair/reconcile corroboration floor. A Cadre
146
- * party legitimately runs one or two nodes, so Cadre leaves it at Optimystic's default of 2;
147
- * asserting 16 there would make the admission gate demand `ceil(0.75 x 16) = 12` declared
148
- * peers and refuse every real party's writes.
149
- *
150
- * NOTE: cohort selection overfetches proportionally to this number —
151
- * `Libp2pKeyPeerNetwork.membershipOverfetch` asks FRET for
152
- * `max(clusterSize * 4, clusterSize + 16)` candidates and does a peerStore protocol lookup
153
- * per candidate, so 16 requests a 64-peer proximity band. Free today, because the result is
154
- * bounded by the peers FRET actually knows (2 in a 3-node party). If control-network cohort
155
- * selection ever shows up as slow on a large mesh, look here first.
156
- */
157
- export const CONTROL_REPLICATION_BREADTH = 16;
158
-
159
- /**
160
- * Cluster consensus policy every CONTROL-network libp2p node runs — production
161
- * (`CadreNode.buildControlNodeOptions`) and the integration harness alike. One definition so
162
- * the two cannot drift: the object literal used to be hand-copied at both sites, and the copy
163
- * at the harness had picked up a `superMajorityThreshold` override production never had.
164
- *
165
- * `superMajorityThreshold` is deliberately ABSENT. Omitting it makes both the cluster member
166
- * and the coordinator fall back to Optimystic's `DEFAULT_SUPER_MAJORITY_THRESHOLD` (0.75) —
167
- * the bar a real party commits control writes against. Setting it here, or at any one
168
- * consumer, reintroduces exactly the divergence this constant exists to prevent. The absence
169
- * is a researched decision, not an inherited default: no threshold both relaxes the
170
- * three-node unanimity bar and satisfies Optimystic's partition-safety condition at the
171
- * shipped admission fraction — see `docs/architecture.md` → "Replication cluster size".
172
- *
173
- * `sizeTolerance` only makes sense alongside `allowDownsize`: a fixed
174
- * {@link CONTROL_REPLICATION_BREADTH}-wide target is unsatisfiable by any real (2-7 node)
175
- * party, so the cohort must be allowed to shrink to the party that actually exists.
176
- *
177
- * NOT for strand networks: {@link STRAND_CLUSTER_POLICY} is a structurally identical object
178
- * for a different network with different reasoning. The shape match is a coincidence; keep
179
- * them separate.
180
- *
181
- * `assumedClusterSize` is REQUIRED here, and 2 is not a guess — it is the smallest party we
182
- * support. The field feeds two Optimystic consumers with opposite failure modes, and only one
183
- * of them defaults to something workable for us:
184
- *
185
- * - The membership admission gate already defaults to 2 (`minAbsoluteClusterSize`), so naming
186
- * it changes nothing there.
187
- * - The read-repair / reconcile corroboration floor does NOT. Absent this field it falls back
188
- * to `clusterSize` — which for the control network is {@link CONTROL_REPLICATION_BREADTH},
189
- * 16. `corroboratorCapacity(peers, 16)` is then `max(peers, 15)`, so the floor of two
190
- * distinct non-self corroborators binds. A two-node party fields exactly one, can never
191
- * reach two, and so can never repair a block: `cluster-fetch:no-quorum` forever.
192
- *
193
- * Declaring 2 does NOT lower the replication factor — that stays at
194
- * {@link CONTROL_REPLICATION_BREADTH}. It states the cohort size we can genuinely field, which
195
- * is what the corroboration floor is asking. See Optimystic's `cluster/cluster-policy.ts`
196
- * ("Why two size yardsticks, not one") and `corroboratorCapacity` in `cluster/quorum-restore.ts`.
197
- *
198
- * **`assumedClusterSize` is now the ADMISSION yardstick.** Since Optimystic grew
199
- * `repairCorroborationClusterSize` (see {@link controlClusterPolicy}), the repair corroboration
200
- * floor prefers that separate declaration and reads this field only when nothing was declared —
201
- * the unknown-machine-count path, where the paragraph above still describes what it does. So on
202
- * every path where the count IS known, the 2 here governs one consumer: the membership admission
203
- * gate's low-confidence fallback path. Leaving
204
- * it at 2 is deliberate and must NOT be "fixed" to match the repair yardstick. Raising it would
205
- * make the gate demand `ceil(0.75 x N)` DECLARED peers before it admits a coordinator's peer
206
- * set on that path, and a Cadre party is phones and laptops: it cannot promise that three
207
- * quarters of its enrolled machines are awake at any moment, so a party of eight with three
208
- * awake would stop transacting entirely. The repair yardstick has no such cost (it gates only
209
- * whether this node believes a repair answer), which is exactly why the two are now separate
210
- * numbers rather than one.
211
- *
212
- * `cohortQueryTimeoutMs` is declared for the same reason it is declared on the strand policy —
213
- * the link, not the network ({@link COHORT_READ_DEADLINE_MS}). Absent it, Optimystic's LAN-shaped
214
- * 1000 ms would leave a control read over a relayed link with no corroborating answer in time.
215
- *
216
- * The `satisfies` is load-bearing: without it a mistyped or obsolete key would compile at both
217
- * this definition and every consumer, because TypeScript only excess-property-checks fresh
218
- * object literals, not a shared constant handed to `clusterPolicy`.
219
- */
220
- export const CONTROL_CLUSTER_POLICY = Object.freeze({
221
- allowDownsize: true,
222
- sizeTolerance: 0.5,
223
- assumedClusterSize: 2,
224
- cohortQueryTimeoutMs: COHORT_READ_DEADLINE_MS,
225
- } satisfies NonNullable<NodeOptions['clusterPolicy']>);
226
-
227
- /**
228
- * Default number of nodes a **strand** network is told its replication cluster should have.
229
- *
230
- * Strand data is application data, where partial replication is a legitimate choice: only
231
- * the control database's every-member-reads-all-of-it character forces full replication
232
- * ({@link CONTROL_REPLICATION_BREADTH}). So the question here is not "everyone" but "how
233
- * few is too few", and the answer is four.
234
- *
235
- * **Why four.** A write commits when a super-majority of the cohort approves, and that bar is
236
- * Optimystic's `DEFAULT_SUPER_MAJORITY_THRESHOLD` of 0.75 — which Cadre selects by naming no
237
- * threshold at all (see {@link CONTROL_CLUSTER_POLICY}). Approvals needed is
238
- * `ceil(cohort x 0.75)`, so:
239
- *
240
- * | copies | approvals needed | holders that may be offline |
241
- * |--------|------------------|-----------------------------|
242
- * | 2 | 2 | 0 |
243
- * | 3 | 3 | 0 |
244
- * | 4 | 3 | 1 |
245
- * | 5 | 4 | 1 |
246
- * | 6 | 5 | 1 |
247
- *
248
- * Four is the smallest breadth that lets a write commit while one holder is away. At two or
249
- * three every holder must be awake for every write, which for a workspace shared between
250
- * phones and laptops is the ordinary case, not the rare one. Six buys no more fault tolerance
251
- * than four and costs more overfetch (see the NOTE below).
252
- *
253
- * **Exercised at four machines** by `integration-tests`'
254
- * `scenarios/strand-two-party-two-machine.integration.ts`, the only scenario that runs a
255
- * strand at this breadth. It shows a write committing while one of the four machines is off.
256
- * It does NOT distinguish that from a commit on a cohort downsized to the three live holders
257
- * (`allowDownsize` in {@link STRAND_CLUSTER_POLICY}), so the table row above remains reasoned
258
- * arithmetic with an end-to-end existence proof under it, not a measurement of cohort width.
259
- *
260
- * **It is also a correctness floor, not only a durability one — but the floor is measured in
261
- * machines, not in this number.** A node that has fallen behind and can ask exactly one peer
262
- * whether it is current has Optimystic accept that single answer as the cluster's truth
263
- * (`corroboratorCapacity` in `db-p2p/src/cluster/quorum-restore.ts` lowers the corroboration
264
- * floor to one when the cohort cannot hold a second voter). If that peer is also behind it
265
- * honestly reports the stale revision, the reader concludes it is current, and re-arms its
266
- * repair window forever. Measured on the control-DB replication scenario: 4 failures in 10 runs
267
- * at breadth 2, 0 in 20 at breadth 3, 0 in 10 at breadth 8. What lifts the floor off a single
268
- * voter is a third machine **or a declaration that there are three** — not a wider target.
269
- * `corroboratorCapacity` takes the max of the cohort peers actually present and the declared
270
- * repair yardstick, so raising *this* number (the replication breadth) does nothing: the
271
- * yardstick is a separate declaration, and **a strand node makes no such declaration today**.
272
- * The control network derives one from the party's enrolled machines
273
- * ({@link controlClusterPolicy}), which is exact there because every enrolled machine runs the
274
- * control node. A strand has no equivalent count: it launches only on machines whose embedder
275
- * registered its sApp config, plus storage-profile machines that host unclaimed strands as
276
- * storage replicas (cadre-core's `CadreNodeConfig.hostUnclaimedStrands`) and whose strand filter
277
- * admits it. Phones serve only what their app claims, so reusing the party count over-declares, which is worse than
278
- * declaring nothing, because at a yardstick of three or more the corroboration floor is pinned
279
- * at two peers and a strand that can field only one could then never repair at all. So every
280
- * strand runs {@link STRAND_CLUSTER_POLICY} unchanged and takes the single-voter exposure
281
- * whenever its cohort view shrinks to one peer. The underlying Optimystic behaviour is unfixed
282
- * (`backlog/debt-read-repair-single-voter-corroboration`) and the per-strand serving count that
283
- * would let a strand declare honestly is `backlog/feat-strand-yardstick-from-serving-machines`
284
- * — its seam ({@link strandClusterPolicy}'s `servingMachines`, threaded through
285
- * `StartStrandConfig.servingMachines`) is already in place and fed by nothing. Raising this
286
- * number does not buy the way out; a machine, or an honest declaration of the machines actually
287
- * serving the strand, does.
288
- *
289
- * **Why not derived from the party or member count.** The strand's `Member` rows live *in* the
290
- * strand database, which runs on the strand libp2p node, whose cluster size is frozen at
291
- * construction — reading the list to configure the node needs the node to exist. Open strands
292
- * have no member list at all. The count also changes in the unsafe direction: a node that
293
- * restarted after someone joined derives a wider expected cohort than one that did not, and the
294
- * membership admission gate's confident path is what rejects the *wider* view. And a `Member` is
295
- * a party, not a machine; cohort width consumes machines. Full reasoning in
296
- * `docs/architecture.md` → "Replication cluster size".
297
- *
298
- * **Four machines, not four parties.** This number counts machines, and today that is not the
299
- * same as counting independent holders. Cohort selection upstream is owner-blind — it
300
- * picks nodes by hash proximity with no concept of which peer belongs to which party
301
- * (`optimystic/tickets/backlog/feat-cohort-selection-owner-aware-placement`) — and on the
302
- * cadre-driven path, the one that resolves a seed for itself, a strand's libp2p mesh is seeded
303
- * from exactly one party's own control-database peer rows (`CadreNode.resolveCohortSeed`), so
304
- * the cohort actually formed is that one party's machines. Four copies are, on that path, four
305
- * machines belonging to one party — one failure domain, one witness. A cross-party mesh has
306
- * exactly one automatic path — a formation result carries the responder's strand addresses and
307
- * `CadreNode.resolveCohortSeed` unions them in, seeding only the pair that just redeemed an
308
- * invitation — and is otherwise hand-supplied addresses
309
- * (`StrandConnectionOptions.bootstrapNodes`) or a hand-written dial (see the doc). Every party
310
- * now holds its own membership identity, so the missing piece is placement, not identity:
311
- * tracked as `backlog/feat-strand-party-diverse-placement`. Full reasoning:
312
- * `docs/architecture.md` → "Replication cluster size".
313
- *
314
- * Raising or lowering it per strand is still the embedder's call
315
- * ({@link resolveStrandClusterSize}, `CadreNodeConfig.strandClusterSize`,
316
- * `StrandConnectionOptions.clusterSize`) — but every node on one strand must agree, so a change
317
- * means restarting all of them. There is no smaller-than-{@link MIN_CLUSTER_SIZE} option.
318
- *
319
- * NOTE: cohort selection overfetches proportionally — `Libp2pKeyPeerNetwork.membershipOverfetch`
320
- * asks FRET for `max(clusterSize * 4, clusterSize + 16)` candidates and does one peerStore
321
- * protocol lookup per candidate, so 4 requests a 20-peer band (was 18 at breadth 2). Negligible,
322
- * and bounded by the peers FRET actually knows. If strand cohort selection ever shows up as slow,
323
- * look here first.
324
- */
325
- export const DEFAULT_STRAND_CLUSTER_SIZE = 4;
326
-
327
- /**
328
- * Cluster consensus policy every STRAND-network libp2p node runs — production
329
- * (`StrandInstanceManager.buildStrandRuntime`) and the plugin's own networked e2e mesh alike.
330
- * One definition so the two cannot drift; the literal used to be hand-copied at both sites.
331
- *
332
- * Structurally identical to {@link CONTROL_CLUSTER_POLICY} and deliberately separate from it:
333
- * that one is the control database's, whose breadth is fixed at
334
- * {@link CONTROL_REPLICATION_BREADTH} for reasons that do not apply to application data.
335
- *
336
- * `superMajorityThreshold` is ABSENT for the same reason it is absent there: omitting it is how
337
- * both the coordinator and the cluster member select Optimystic's
338
- * `DEFAULT_SUPER_MAJORITY_THRESHOLD` (0.75), the bar {@link DEFAULT_STRAND_CLUSTER_SIZE}'s
339
- * copies/approvals table is computed against.
340
- *
341
- * `sizeTolerance` only makes sense alongside `allowDownsize`: a {@link
342
- * DEFAULT_STRAND_CLUSTER_SIZE}-wide target is unsatisfiable by a strand of one, two or three
343
- * machines, so the cohort must be allowed to shrink to the mesh that actually exists.
344
- *
345
- * `assumedClusterSize` is REQUIRED, and {@link MIN_CLUSTER_SIZE} is not a guess — it is the
346
- * smallest strand that reaches the cluster path at all. Absent this field the read-repair /
347
- * reconcile corroboration floor falls back to `clusterSize`, so
348
- * `corroboratorCapacity(peers, 4)` is `max(peers, 3)` and two distinct non-self corroborators
349
- * become mandatory. A two-machine strand fields exactly one peer, can never reach two, and so
350
- * can never repair a block — `cluster-fetch:no-quorum` forever, which is a read that never
351
- * catches up and a `create table` that dies with `Missing block` on the joining node. Declaring
352
- * the honest floor does NOT lower the replication factor (that stays at
353
- * {@link DEFAULT_STRAND_CLUSTER_SIZE}) and does not relax anything a strand with three or more
354
- * machines does: `corroboratorCapacity` takes the MAX of the cohort actually present and this
355
- * number, so the relaxed branch is reachable only by a cohort that is genuinely that small. See
356
- * Optimystic's `cluster/cluster-policy.ts` ("Why two size yardsticks, not one").
357
- *
358
- * `assumedClusterSize` also carries the ADMISSION yardstick, and on the strand path it carries
359
- * BOTH jobs today. Optimystic prefers a separately declared
360
- * `repairCorroborationClusterSize` for the repair floor and falls back to this field only when
361
- * nothing was declared — which, for a strand, is always: no per-strand serving count exists yet,
362
- * so {@link strandClusterPolicy} declares nothing and the 2 here governs the repair floor
363
- * exactly as the paragraph above describes, alongside the membership admission gate's
364
- * low-confidence fallback. Do not raise it if a strand yardstick ever does get declared
365
- * (`backlog/feat-strand-yardstick-from-serving-machines`): a strand shared between phones cannot
366
- * promise that `ceil(0.75 x N)` of its machines are awake, and the admission gate would refuse
367
- * its writes. Same reason and same warning as {@link CONTROL_CLUSTER_POLICY}'s.
368
- *
369
- * `cohortQueryTimeoutMs` is {@link COHORT_READ_DEADLINE_MS}, the same value the control policy
370
- * declares. That is the one field the two networks deliberately agree on by argument rather than
371
- * by coincidence: it is a property of the LINK, and a phone's control node and its strand nodes
372
- * ride the same one.
373
- *
374
- * The `satisfies` is load-bearing for the same reason as {@link CONTROL_CLUSTER_POLICY}'s.
375
- */
376
- export const STRAND_CLUSTER_POLICY = Object.freeze({
377
- allowDownsize: true,
378
- sizeTolerance: 0.5,
379
- assumedClusterSize: MIN_CLUSTER_SIZE,
380
- cohortQueryTimeoutMs: COHORT_READ_DEADLINE_MS,
381
- } satisfies NonNullable<NodeOptions['clusterPolicy']>);
382
-
383
- /**
384
- * Resolve the cluster size to hand `createLibp2pNode` for a **strand** network, applying
385
- * {@link DEFAULT_STRAND_CLUSTER_SIZE} and rejecting values Optimystic cannot honour. The
386
- * control network does not route through here — it uses the fixed
387
- * {@link CONTROL_REPLICATION_BREADTH}.
388
- *
389
- * Leaving the value unset is NOT the same as passing the default: Optimystic's own fallback
390
- * is 10, so every strand node-creating path must route through here.
391
- *
392
- * Every node on the same strand should resolve to the same value: the membership admission
393
- * gate's *confident* path compares a coordinator's declared set against the member's own
394
- * cohort view, and that view is bounded by this number, so a member configured much higher
395
- * can reject a smaller declared set as a downsize. Divergence is a live hazard whenever FRET
396
- * has a confident network-size estimate — not the unconditional refusal earlier revisions of
397
- * this comment described. See `docs/architecture.md` → "Replication cluster size".
398
- */
399
- export function resolveStrandClusterSize(configured?: number): number {
400
- if (configured === undefined) {
401
- return DEFAULT_STRAND_CLUSTER_SIZE;
402
- }
403
- if (!Number.isInteger(configured) || configured < MIN_CLUSTER_SIZE) {
404
- throw new Error(
405
- `clusterSize must be an integer >= ${MIN_CLUSTER_SIZE} (Optimystic's minimum cluster size); got ${configured}`
406
- );
407
- }
408
- return configured;
409
- }
410
-
411
- /**
412
- * The repair yardstick to declare for a network, from the machines that SERVE that network and
413
- * the breadth it replicates to.
414
- *
415
- * ## What this number does
416
- *
417
- * Optimystic's block repair (read-repair and reconcile) asks a block's cohort for the newest
418
- * revision and trusts an answer only when enough independent peers agree on it. "Enough" is
419
- * measured against a **declared** number rather than the peers currently visible — deliberately,
420
- * because the visible set comes from unauthenticated routing, and a partition (or an attacker
421
- * with routing influence) can shrink it. `clusterPolicy.repairCorroborationClusterSize` is that
422
- * declaration; it moves the repair yardstick ALONE, leaving
423
- * {@link CONTROL_CLUSTER_POLICY}'s / {@link STRAND_CLUSTER_POLICY}'s `assumedClusterSize` — the
424
- * membership admission gate's yardstick — untouched. Optimystic resolves
425
- * `repairCorroborationClusterSize -> assumedClusterSize -> clusterSize`, and a value that is not
426
- * a positive integer falls THROUGH to the next term rather than being clamped, which is why the
427
- * builders below refuse to pass a degenerate count at all.
428
- *
429
- * ## The arithmetic, and why the formula is what it is
430
- *
431
- * Three consumers read the resolved yardstick `N`. With `p` = cohort peers currently visible
432
- * (self excluded) and Optimystic's `CORROBORATION_FLOOR` = 2:
433
- *
434
- * - **Repair corroboration floor** (`corroboratorCapacity` / `quorumSize` in
435
- * `db-p2p/src/cluster/quorum-restore.ts`): capacity is `max(p, N - 1)` and the floor is
436
- * `max(1, min(2, capacity))`. So `N = 1` and `N = 2` behave IDENTICALLY at every `p` — both
437
- * leave the floor at a single voter whenever `p <= 1`. `N >= 3` pins the floor at two
438
- * corroborators unconditionally, including when the view has shrunk to one peer. That is the
439
- * entire point of declaring. `N` above 3 raises nothing further (the `min(2, ...)` caps it), so
440
- * a larger declaration buys only honesty, never more repair.
441
- * - **Commit freshness window** (`CoordinatorRepo.commitQuorumRulesOutRivals`): `fullCohortSize`
442
- * is `max(observed cohort, N)`, and a local commit arms the lazy read-repair window only when
443
- * `approvals > fullCohortSize / 2`. Here a larger `N` is NOT free — see the NOTE below.
444
- * - The `repair-fault-tolerance` startup advisory — log text only.
445
- *
446
- * Hence `max(MIN_CLUSTER_SIZE, min(servingMachines, replicationBreadth))`, two clamps each for
447
- * a stated reason:
448
- *
449
- * - **Capped at the replication breadth**, because a block only ever lives on
450
- * `min(breadth, machines serving)` machines. A party of eight running strands at
451
- * {@link DEFAULT_STRAND_CLUSTER_SIZE} has a cohort of four; declaring eight would make
452
- * `approvals > 4` unsatisfiable (a super-majority of four is three), so the freshness window
453
- * would never arm for any strand commit, forever — and buy nothing on repair, whose floor is
454
- * already at its cap at four. Declare the machines that can hold the block, not the machines
455
- * that exist.
456
- * - **Floored at {@link MIN_CLUSTER_SIZE}**, so this can only ever RAISE the declared number
457
- * relative to the un-derived constants. A node that authorizes nobody is a genuine
458
- * founder-alone party, but it is equally a freshly seeded node whose membership rows have not
459
- * replicated yet, or one whose trusted-owner anchor is empty — and this node cannot tell those
460
- * apart. Declaring 1 is safety-neutral for repair (identical to 2 at every `p`, per the table
461
- * above) but it WOULD let a solo commit arm the freshness window, which is wrong if the "solo"
462
- * reading came from stale local records. Lifting that floor for a node that can prove it is
463
- * genuinely alone is separate work: `backlog/feat-solo-node-arms-its-own-freshness-window`.
464
- *
465
- * The yardstick is per-node and protects only that node's own reads — no node refuses another
466
- * anything over it — so two members disagreeing (one has replicated a new `CadrePeer` row, one
467
- * has not) is harmless by construction, not a race to close.
468
- *
469
- * ## The contract: callers pass machines that SERVE this network, or nothing at all
470
- *
471
- * Over-declaring is not merely wasteful, it is unsafe in the availability direction: at
472
- * `N >= 3` the corroboration floor is pinned at two peers, so a cohort that can only ever
473
- * field one peer can never repair at all (`cluster-fetch:no-quorum`), where a declaration of
474
- * 2 would have let its single peer answer. So the number a caller passes must be the machines
475
- * that actually serve the network in question — never a broader population that merely bounds
476
- * it from above, and never a guess. A caller with no trustworthy count passes nothing, which
477
- * the builders below turn into "declare no yardstick at all".
478
- *
479
- * The CONTROL network satisfies this from the party's enrolled machines, because every enrolled
480
- * machine runs the control node by construction: there, enrolled IS serving
481
- * ({@link controlClusterPolicy}, fed by cadre-core's `enrolled-machine-store.ts`). A STRAND does
482
- * not — it launches only on machines whose embedder registered its sApp config
483
- * (`CadreNode.addStrand`), plus storage-profile machines hosting it as a storage replica
484
- * (`CadreNodeConfig.hostUnclaimedStrands`) whose strand filter admits it. A closed strand
485
- * claimed by two phones of a three-phone party is served by two, and declaring three would be
486
- * exactly the unsafe over-declaration above. No
487
- * authenticated per-strand serving count exists yet, so strand nodes declare NOTHING and run the
488
- * frozen {@link STRAND_CLUSTER_POLICY} — keeping the known, upstream-tracked single-voter
489
- * exposure (`backlog/debt-read-repair-single-voter-corroboration`) rather than risking the
490
- * cannot-repair-at-all failure. Building that count:
491
- * `backlog/feat-strand-yardstick-from-serving-machines`.
492
- *
493
- * Both arguments must be positive integers; the builders below sanitize before calling, and
494
- * a degenerate value here propagates (`NaN` in, `NaN` out) rather than being clamped.
495
- *
496
- * NOTE: on a party of three or more machines where fewer than half are awake, a commit reaches a
497
- * downsized cohort and no longer arms the freshness window, so each such block's next read costs
498
- * one cohort consult per read-repair window instead of trusting the local commit. That is the
499
- * correct reading of the honest number — a commit that reached two of five machines genuinely
500
- * does not rule out a rival quorum, and arming it today is a consequence of under-declaring — and
501
- * the cost is bounded at one consult per block per window by the read path's solo-self-skip exit.
502
- * NOT measured under load. If a large, mostly-hibernating party's read path ever shows up as
503
- * slow, this is the first thing to look at.
504
- */
505
- export function resolveRepairYardstick(servingMachines: number, replicationBreadth: number): number {
506
- return Math.max(MIN_CLUSTER_SIZE, Math.min(servingMachines, replicationBreadth));
507
- }
508
-
509
- /**
510
- * A serving-machine count as a usable number, or `undefined` for "this node does not know".
511
- * Anything that is not a positive integer is unknown rather than clamped: Optimystic itself
512
- * treats a degenerate declaration as absent, so silently rounding one here would hide a caller
513
- * bug behind a number nobody chose.
514
- */
515
- function asKnownMachineCount(servingMachines?: number): number | undefined {
516
- return servingMachines !== undefined
517
- && Number.isInteger(servingMachines)
518
- && servingMachines >= 1
519
- ? servingMachines
520
- : undefined;
521
- }
522
-
523
- /**
524
- * The per-network declarations `controlClusterPolicy` and `strandClusterPolicy` accept, reduced to
525
- * the two things they have in common. Named rather than positional at the public builders because
526
- * every one of these is a `number` and they mean unrelated things: a machine COUNT and a
527
- * millisecond DURATION transposed would compile, and would put a 5 ms read deadline or a yardstick
528
- * of 5000 on a real node. A third number is already queued for these builders
529
- * (`backlog/feat-strand-yardstick-from-serving-machines`).
530
- */
531
- interface PolicyDeclarations {
532
- /** The frozen base policy to declare onto. */
533
- base: NonNullable<NodeOptions['clusterPolicy']>;
534
- /** Ceiling on the repair yardstick — a block never lives on more machines than the cohort is wide. */
535
- yardstickCap: number;
536
- /** Machines serving this network; a degenerate value is treated as absent. */
537
- machines: number | undefined;
538
- /** The host's per-peer read deadline in place of {@link COHORT_READ_DEADLINE_MS}. */
539
- cohortQueryTimeoutMs: number | undefined;
540
- }
541
-
542
- /**
543
- * The base policy with whatever was declared onto it — or the base object ITSELF, by identity,
544
- * when nothing was, which is the production path for both networks today and so is provably
545
- * byte-for-byte the old behaviour.
546
- *
547
- * The two spreads are independent, so neither declaration can shadow the other: they arrive from
548
- * unrelated sources (a node-local machine record, the host's network config) and either may be
549
- * present alone.
550
- *
551
- * `cohortQueryTimeoutMs` is passed through UNVALIDATED, by design. Optimystic already refuses a
552
- * value that is not a finite number above zero, or is above its `MAX_COHORT_QUERY_TIMEOUT_MS`, and
553
- * it THROWS where the libp2p node is constructed rather than falling back to its own default — so a
554
- * second check here would only duplicate a message the host already gets, at a site that cannot
555
- * name the node being built.
556
- */
557
- function declarePolicy(
558
- { base, yardstickCap, machines, cohortQueryTimeoutMs }: PolicyDeclarations
559
- ): NonNullable<NodeOptions['clusterPolicy']> {
560
- const known = asKnownMachineCount(machines);
561
- if (known === undefined && cohortQueryTimeoutMs === undefined) {
562
- return base;
563
- }
564
- return Object.freeze({
565
- ...base,
566
- ...(known !== undefined && {
567
- repairCorroborationClusterSize: resolveRepairYardstick(known, yardstickCap)
568
- }),
569
- ...(cohortQueryTimeoutMs !== undefined && { cohortQueryTimeoutMs })
570
- } satisfies NonNullable<NodeOptions['clusterPolicy']>);
571
- }
572
-
573
- /**
574
- * {@link CONTROL_CLUSTER_POLICY} with the block-repair corroboration yardstick declared from
575
- * `enrolledMachines` (see {@link resolveRepairYardstick}) and the per-peer read deadline replaced
576
- * by the host's `cohortQueryTimeoutMs`; the frozen base object ITSELF when neither is declared, so
577
- * identity assertions keep holding — including for the host that passes a `network` block with
578
- * neither field in it, whose declarations arrive present-but-`undefined`.
579
- *
580
- * The count is named `enrolledMachines` here, unlike {@link strandClusterPolicy}'s, because for the
581
- * CONTROL network the two quantities are one and the same by construction: every enrolled machine
582
- * runs the control node, so the machines enrolled in the party ARE the machines serving this
583
- * network.
584
- */
585
- export function controlClusterPolicy(
586
- declared: { enrolledMachines?: number; cohortQueryTimeoutMs?: number } = {}
587
- ): NonNullable<NodeOptions['clusterPolicy']> {
588
- return declarePolicy({
589
- base: CONTROL_CLUSTER_POLICY,
590
- yardstickCap: CONTROL_REPLICATION_BREADTH,
591
- machines: declared.enrolledMachines,
592
- cohortQueryTimeoutMs: declared.cohortQueryTimeoutMs
593
- });
594
- }
595
-
596
- /**
597
- * {@link STRAND_CLUSTER_POLICY} with the block-repair corroboration yardstick declared from
598
- * `servingMachines` — the machines that serve THIS strand — capped at this strand's own
599
- * `clusterSize` (already resolved by {@link resolveStrandClusterSize}, so it is at least
600
- * {@link MIN_CLUSTER_SIZE} and the formula's two clamps can never cross), and the per-peer read
601
- * deadline replaced by the host's `cohortQueryTimeoutMs`; the frozen base object ITSELF when
602
- * neither is declared.
603
- *
604
- * **The no-count path is the production path today.** No authenticated per-strand serving count
605
- * exists, and the party's enrolled-machine count is emphatically not one (see
606
- * {@link resolveRepairYardstick}'s contract section), so cadre-core declares no count and every
607
- * strand node of a host that declared no deadline runs the frozen constant. The field and its
608
- * threading through `StartStrandConfig.servingMachines` stay in place as the seam that
609
- * `backlog/feat-strand-yardstick-from-serving-machines` plugs a real count into.
610
- */
611
- export function strandClusterPolicy(
612
- clusterSize: number,
613
- declared: { servingMachines?: number; cohortQueryTimeoutMs?: number } = {}
614
- ): NonNullable<NodeOptions['clusterPolicy']> {
615
- return declarePolicy({
616
- base: STRAND_CLUSTER_POLICY,
617
- yardstickCap: clusterSize,
618
- machines: declared.servingMachines,
619
- cohortQueryTimeoutMs: declared.cohortQueryTimeoutMs
620
- });
621
- }
1
+ import type { NodeOptions } from '@optimystic/db-p2p';
2
+
3
+ /**
4
+ * Smallest cluster size Optimystic will honour — its own `minAbsoluteClusterSize`,
5
+ * and the smallest value that reaches the cluster path at all (a lone node writes to
6
+ * local storage without forming a cluster). A validation floor, not a default.
7
+ */
8
+ export const MIN_CLUSTER_SIZE = 2;
9
+
10
+ /**
11
+ * How long ONE cohort peer gets to answer ONE read-path request, in milliseconds — both
12
+ * networks' `clusterPolicy.cohortQueryTimeoutMs`. It sets the latest-revision query a read
13
+ * runs when its local copy may be stale (`CoordinatorRepo.queryClusterForLatest`) and the
14
+ * archive fetch that follows it (`fetchArchiveFromPeer`); Optimystic ties the two to one
15
+ * field because they are the same round trip to the same peer. Optimystic's own default is
16
+ * 1000 ms, a LAN-shaped budget, and it deliberately left that default alone — choosing the
17
+ * value for a deployment of phones is the embedder's job.
18
+ *
19
+ * **Why 7000: it is a derivation, written out.** One read-path request opens a fresh protocol
20
+ * stream over a connection that is already open, which costs the protocol negotiation plus the
21
+ * request and its answer: two link round trips, `CIRCUIT_REQUEST_ROUND_TRIPS` in cadre-core's
22
+ * `link-budget.ts`. At the link round trip cadre declares by default,
23
+ * `DECLARED_LINK_ROUND_TRIP_MS` (3 500 ms, covering the 3-second relayed round trip sereus
24
+ * supports), that is 2 x 3 500 = 7 000 ms — `cohortReadDeadlineMs()` in that module. This
25
+ * constant must equal it, and `cadre-core/test/link-budget.spec.ts` pins the two equal; it is
26
+ * spelled as a number here only because this package cannot import cadre-core (the dependency
27
+ * runs the other way). A host that declares its own link (`NetworkConfig.linkRoundTripMs`)
28
+ * gets the derivation at that link instead of this constant, and a host that sets
29
+ * `NetworkConfig.cohortQueryTimeoutMs` gets exactly what it set. No admission allowance is
30
+ * added: a read runs over an open connection and opens none. The whole-pass reconcile bound
31
+ * Optimystic derives from it (`max(5000, 5 x this)`) becomes 35 s.
32
+ *
33
+ * **History: why 5000 before, and why it was not enough.** Optimystic GitHub #22, reported by
34
+ * a sereus-based chat app: two parties that reach each other only through a public circuit
35
+ * relay have a round trip near 1.8 s, and a fresh stream (dial or reuse, protocol select, send,
36
+ * receive) cannot finish inside a second. At a cohort of two there is exactly one peer to ask,
37
+ * so one late answer leaves the read with nothing to corroborate against and it is declined.
38
+ * 5000 was chosen to cover 1.8 s with room for the Noise handshake, the relay hops and a
39
+ * phone's pure-JS crypto (cadre-core's `NetworkConfig.noiseCrypto`), and it is the value
40
+ * Optimystic's own note advises for that link. But sereus supports a 3 s round trip, and two
41
+ * round trips of pure delay at that link is 6 s: above 5 000, so at the supported link every
42
+ * consult timed out and every read had nothing to corroborate against — the #22 failure again,
43
+ * one band up. The derivation ends that class: the next person to widen the link changes one
44
+ * declaration.
45
+ *
46
+ * **Measured at 5000**, 2026-09-26, one Windows developer machine, two relay-only `CadreNode`s
47
+ * on one shared loopback dedicated relay, the one-way outbound frame delay raised to 900 ms
48
+ * (`pipelined` mode — see `docs/testing.md` → "Where measurements live") AFTER the strand had
49
+ * formed and first synced, for a round trip near 1.8 s. A machine that had joined, gone away
50
+ * while the other wrote, then re-attached over its own stale store and read the row written
51
+ * while it was away. Declined reads over that journey, after the delay was raised: **18 at
52
+ * Optimystic's 1000 ms, 2 at 5000 ms.** A declined read is
53
+ * `cluster-fetch:peers-silent { silent: 1, consulted: 2 }` followed by
54
+ * `cluster-fetch:no-quorum`, retried about a second later until one lands; at 5000 the
55
+ * corroboration succeeded instead (`cluster-fetch:local-current`). Both journeys COMPLETED at
56
+ * either value, which is why no committed scenario gates this — 900 ms is not where it breaks.
57
+ * The recipe for re-measuring is in `docs/testing.md` → "Where measurements live".
58
+ *
59
+ * **Measured at 7000 against 5000**, 2026-09-29, same machine and shape, at 1 500 ms one-way
60
+ * (the supported 3 s round trip), fresh-join arm, one run each under
61
+ * `DEBUG=optimystic:db-p2p:coordinator-repo*`, on `@optimystic/*` 1.7.0: **39 declined reads at
62
+ * 5000, 32 at 7000**, both runs completing (writable at 70.2 s and 63.6 s). That difference
63
+ * was noise, not the deadline at work: in both runs every consult on the joiner ended
64
+ * 3.00-3.02 s after the one before it, the signature of Optimystic's then-fixed 3 000 ms
65
+ * request dial deadline, which also bounded the protocol negotiation on an ALREADY-OPEN
66
+ * connection, where the negotiation alone costs one link round trip — 3 s at this delay.
67
+ *
68
+ * **Measured at 7000 on 1.8.0**, the same day and shape, once a consult ran under this deadline
69
+ * alone and cadre-core stated its declared link to Optimystic: **no declined read** during the
70
+ * measured attach, over two fresh joins and three re-attaches, and every consult on the joiner
71
+ * completed 6.03-6.05 s after the one before it — the two round trips this deadline is derived
72
+ * from, with about 0.95 s to spare. The first-sync figures from those runs are on
73
+ * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`.
74
+ *
75
+ * **What it costs**, in the terms Optimystic's own field doc uses: a peer that is truly gone
76
+ * now holds a read of a block missing locally for up to 7 s before the read is declined
77
+ * instead of 5 s (1 s at Optimystic's default). A joining machine's first sync runs several
78
+ * such consults, so it is the cost that binds — and the first-sync band measured at each
79
+ * deadline, against the budget it has to fit, is recorded once, on
80
+ * `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` in cadre-core's `strand-first-sync-gate.ts`. At 5000
81
+ * against the 30 s budget that once preceded it, that change alone would have turned a
82
+ * slow-but-working join into the very `StrandAwaitingFirstSyncError` the report named.
83
+ *
84
+ * **What else it lengthens.** Every cadre-core deadline that waits on an Optimystic read or
85
+ * commit waits on this one too; which of them cut the read off on purpose and which must
86
+ * outlast it is listed in `docs/cadre-consistency.md` → "Deadlines Over Optimystic's Reads and
87
+ * Commits".
88
+ *
89
+ * **One value for both networks, not two.** The reason to widen is the link, and a phone's
90
+ * control node and its strand nodes share it; nothing about control traffic or strand traffic
91
+ * argues for different numbers. A host that needs another value moves both at once —
92
+ * cadre-core's `NetworkConfig.cohortQueryTimeoutMs` or `NetworkConfig.linkRoundTripMs`,
93
+ * threaded to {@link controlClusterPolicy} and {@link strandClusterPolicy} through one helper
94
+ * (`declaredCohortReadDeadlineMs` in cadre-core's `link-budget.ts`).
95
+ *
96
+ * NOTE: this number and the first-sync budget are coupled; the margin between that budget and
97
+ * the slowest first sync measured is stated on it. Re-measure the first-sync band before
98
+ * raising the declared link round trip, or when a deployment's first sync grows more
99
+ * collections than the two-table scenario measured above — `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS`
100
+ * carries the result and `docs/testing.md` the recipe.
101
+ * Nothing warns when the margin goes; the symptom is `StrandAwaitingFirstSyncError` on a join
102
+ * that was progressing normally, which is the failure this pair of numbers exists to end.
103
+ */
104
+ export const COHORT_READ_DEADLINE_MS = 7000;
105
+
106
+ /**
107
+ * How many nodes each block of the **control** (cadre membership) database is replicated
108
+ * to. Chosen to exceed any party's node count, so in practice every control block lands
109
+ * on every member of the party. Cohort width alone only delivers that for blocks written
110
+ * while the members were serving the network: a block committed while the writer was
111
+ * alone (the founder's genesis above all — collection headers are written once and never
112
+ * revised) has a cohort of one, and it reaches later joiners through cadre-core's
113
+ * peer-join block catch-up (`peer-join-backfill.ts`, membership-gated on the control
114
+ * network), not through this breadth.
115
+ *
116
+ * **Why full replication here.** Every control node reads the whole control database —
117
+ * membership, peer addresses, the strand list — so a member left out of a block's cohort is
118
+ * a member that may never learn the fact, and at a cohort of two the *read repair* that is
119
+ * supposed to make that safe cannot converge (it can ask exactly one peer, and that peer may
120
+ * be the member that also missed the write). Measured: 4 failures in 10 runs of the
121
+ * control-DB replication scenario at size 2, 0 in 20 at size 3 and 0 in 10 at size 8.
122
+ *
123
+ * **Why this number, and why a constant.** Optimystic caps a cohort at the peers that
124
+ * actually serve the network and downsizes a cohort it cannot fill (`allowDownsize: true`,
125
+ * which Cadre passes), so any value at or above the party's node count yields the same
126
+ * behaviour: cohort = whole party. 16 is roughly twice the largest deployment the product
127
+ * documents (`docs/architecture.md` → "Enterprise (Multi-Node Mixed)", 7 nodes) — headroom
128
+ * without pretending to support arbitrarily large parties; a party genuinely running more
129
+ * than 16 nodes is back to partial replication for its control database and should raise
130
+ * this. It is a constant rather than the live member count because Optimystic freezes the
131
+ * value at libp2p-node construction, before the `ControlDatabase` holding the `CadrePeer`
132
+ * rows exists, and because a per-node derivation lets two members disagree.
133
+ *
134
+ * A cadre is one party by construction, so the owner-blind cohort-selection question that
135
+ * applies to strand networks (see {@link DEFAULT_STRAND_CLUSTER_SIZE} below) is not a question
136
+ * here: "replicate to the whole party" already is the whole intent, and there is no party
137
+ * diversity for this breadth to buy or fail to buy.
138
+ *
139
+ * Canonical explanation of all of the above — the read-repair mechanics, what whole-party
140
+ * breadth does and does not remove, and what the wider cohort costs in write availability —
141
+ * is `docs/architecture.md` → "Replication cluster size". Keep it there, not here.
142
+ *
143
+ * **Deliberately not `clusterPolicy.assumedClusterSize`.** That option means "the smallest
144
+ * cohort this deployment can genuinely field", and it feeds both the membership admission
145
+ * gate's low-confidence path *and* the read-repair/reconcile corroboration floor. A Cadre
146
+ * party legitimately runs one or two nodes, so Cadre leaves it at Optimystic's default of 2;
147
+ * asserting 16 there would make the admission gate demand `ceil(0.75 x 16) = 12` declared
148
+ * peers and refuse every real party's writes.
149
+ *
150
+ * NOTE: cohort selection overfetches proportionally to this number —
151
+ * `Libp2pKeyPeerNetwork.membershipOverfetch` asks FRET for
152
+ * `max(clusterSize * 4, clusterSize + 16)` candidates and does a peerStore protocol lookup
153
+ * per candidate, so 16 requests a 64-peer proximity band. Free today, because the result is
154
+ * bounded by the peers FRET actually knows (2 in a 3-node party). If control-network cohort
155
+ * selection ever shows up as slow on a large mesh, look here first.
156
+ */
157
+ export const CONTROL_REPLICATION_BREADTH = 16;
158
+
159
+ /**
160
+ * Cluster consensus policy every CONTROL-network libp2p node runs — production
161
+ * (`CadreNode.buildControlNodeOptions`) and the integration harness alike. One definition so
162
+ * the two cannot drift: the object literal used to be hand-copied at both sites, and the copy
163
+ * at the harness had picked up a `superMajorityThreshold` override production never had.
164
+ *
165
+ * `superMajorityThreshold` is deliberately ABSENT. Omitting it makes both the cluster member
166
+ * and the coordinator fall back to Optimystic's `DEFAULT_SUPER_MAJORITY_THRESHOLD` (0.75) —
167
+ * the bar a real party commits control writes against. Setting it here, or at any one
168
+ * consumer, reintroduces exactly the divergence this constant exists to prevent. The absence
169
+ * is a researched decision, not an inherited default: no threshold both relaxes the
170
+ * three-node unanimity bar and satisfies Optimystic's partition-safety condition at the
171
+ * shipped admission fraction — see `docs/architecture.md` → "Replication cluster size".
172
+ *
173
+ * `sizeTolerance` only makes sense alongside `allowDownsize`: a fixed
174
+ * {@link CONTROL_REPLICATION_BREADTH}-wide target is unsatisfiable by any real (2-7 node)
175
+ * party, so the cohort must be allowed to shrink to the party that actually exists.
176
+ *
177
+ * NOT for strand networks: {@link STRAND_CLUSTER_POLICY} is a structurally identical object
178
+ * for a different network with different reasoning. The shape match is a coincidence; keep
179
+ * them separate.
180
+ *
181
+ * `assumedClusterSize` is REQUIRED here, and 2 is not a guess — it is the smallest party we
182
+ * support. The field feeds two Optimystic consumers with opposite failure modes, and only one
183
+ * of them defaults to something workable for us:
184
+ *
185
+ * - The membership admission gate already defaults to 2 (`minAbsoluteClusterSize`), so naming
186
+ * it changes nothing there.
187
+ * - The read-repair / reconcile corroboration floor does NOT. Absent this field it falls back
188
+ * to `clusterSize` — which for the control network is {@link CONTROL_REPLICATION_BREADTH},
189
+ * 16. `corroboratorCapacity(peers, 16)` is then `max(peers, 15)`, so the floor of two
190
+ * distinct non-self corroborators binds. A two-node party fields exactly one, can never
191
+ * reach two, and so can never repair a block: `cluster-fetch:no-quorum` forever.
192
+ *
193
+ * Declaring 2 does NOT lower the replication factor — that stays at
194
+ * {@link CONTROL_REPLICATION_BREADTH}. It states the cohort size we can genuinely field, which
195
+ * is what the corroboration floor is asking. See Optimystic's `cluster/cluster-policy.ts`
196
+ * ("Why two size yardsticks, not one") and `corroboratorCapacity` in `cluster/quorum-restore.ts`.
197
+ *
198
+ * **`assumedClusterSize` is now the ADMISSION yardstick.** Since Optimystic grew
199
+ * `repairCorroborationClusterSize` (see {@link controlClusterPolicy}), the repair corroboration
200
+ * floor prefers that separate declaration and reads this field only when nothing was declared —
201
+ * the unknown-machine-count path, where the paragraph above still describes what it does. So on
202
+ * every path where the count IS known, the 2 here governs one consumer: the membership admission
203
+ * gate's low-confidence fallback path. Leaving
204
+ * it at 2 is deliberate and must NOT be "fixed" to match the repair yardstick. Raising it would
205
+ * make the gate demand `ceil(0.75 x N)` DECLARED peers before it admits a coordinator's peer
206
+ * set on that path, and a Cadre party is phones and laptops: it cannot promise that three
207
+ * quarters of its enrolled machines are awake at any moment, so a party of eight with three
208
+ * awake would stop transacting entirely. The repair yardstick has no such cost (it gates only
209
+ * whether this node believes a repair answer), which is exactly why the two are now separate
210
+ * numbers rather than one.
211
+ *
212
+ * `cohortQueryTimeoutMs` is declared for the same reason it is declared on the strand policy —
213
+ * the link, not the network ({@link COHORT_READ_DEADLINE_MS}). Absent it, Optimystic's LAN-shaped
214
+ * 1000 ms would leave a control read over a relayed link with no corroborating answer in time.
215
+ *
216
+ * The `satisfies` is load-bearing: without it a mistyped or obsolete key would compile at both
217
+ * this definition and every consumer, because TypeScript only excess-property-checks fresh
218
+ * object literals, not a shared constant handed to `clusterPolicy`.
219
+ */
220
+ export const CONTROL_CLUSTER_POLICY = Object.freeze({
221
+ allowDownsize: true,
222
+ sizeTolerance: 0.5,
223
+ assumedClusterSize: 2,
224
+ cohortQueryTimeoutMs: COHORT_READ_DEADLINE_MS,
225
+ } satisfies NonNullable<NodeOptions['clusterPolicy']>);
226
+
227
+ /**
228
+ * Default number of nodes a **strand** network is told its replication cluster should have.
229
+ *
230
+ * Strand data is application data, where partial replication is a legitimate choice: only
231
+ * the control database's every-member-reads-all-of-it character forces full replication
232
+ * ({@link CONTROL_REPLICATION_BREADTH}). So the question here is not "everyone" but "how
233
+ * few is too few", and the answer is four.
234
+ *
235
+ * **Why four.** A write commits when a super-majority of the cohort approves, and that bar is
236
+ * Optimystic's `DEFAULT_SUPER_MAJORITY_THRESHOLD` of 0.75 — which Cadre selects by naming no
237
+ * threshold at all (see {@link CONTROL_CLUSTER_POLICY}). Approvals needed is
238
+ * `ceil(cohort x 0.75)`, so:
239
+ *
240
+ * | copies | approvals needed | holders that may be offline |
241
+ * |--------|------------------|-----------------------------|
242
+ * | 2 | 2 | 0 |
243
+ * | 3 | 3 | 0 |
244
+ * | 4 | 3 | 1 |
245
+ * | 5 | 4 | 1 |
246
+ * | 6 | 5 | 1 |
247
+ *
248
+ * Four is the smallest breadth that lets a write commit while one holder is away. At two or
249
+ * three every holder must be awake for every write, which for a workspace shared between
250
+ * phones and laptops is the ordinary case, not the rare one. Six buys no more fault tolerance
251
+ * than four and costs more overfetch (see the NOTE below).
252
+ *
253
+ * **Exercised at four machines** by `integration-tests`'
254
+ * `scenarios/strand-two-party-two-machine.integration.ts`, the only scenario that runs a
255
+ * strand at this breadth. It shows a write committing while one of the four machines is off.
256
+ * It does NOT distinguish that from a commit on a cohort downsized to the three live holders
257
+ * (`allowDownsize` in {@link STRAND_CLUSTER_POLICY}), so the table row above remains reasoned
258
+ * arithmetic with an end-to-end existence proof under it, not a measurement of cohort width.
259
+ *
260
+ * **It is also a correctness floor, not only a durability one — but the floor is measured in
261
+ * machines, not in this number.** A node that has fallen behind and can ask exactly one peer
262
+ * whether it is current has Optimystic accept that single answer as the cluster's truth
263
+ * (`corroboratorCapacity` in `db-p2p/src/cluster/quorum-restore.ts` lowers the corroboration
264
+ * floor to one when the cohort cannot hold a second voter). If that peer is also behind it
265
+ * honestly reports the stale revision, the reader concludes it is current, and re-arms its
266
+ * repair window forever. Measured on the control-DB replication scenario: 4 failures in 10 runs
267
+ * at breadth 2, 0 in 20 at breadth 3, 0 in 10 at breadth 8. What lifts the floor off a single
268
+ * voter is a third machine **or a declaration that there are three** — not a wider target.
269
+ * `corroboratorCapacity` takes the max of the cohort peers actually present and the declared
270
+ * repair yardstick, so raising *this* number (the replication breadth) does nothing: the
271
+ * yardstick is a separate declaration, and **a strand node makes no such declaration today**.
272
+ * The control network derives one from the party's enrolled machines
273
+ * ({@link controlClusterPolicy}), which is exact there because every enrolled machine runs the
274
+ * control node. A strand has no equivalent count: it launches only on machines whose embedder
275
+ * registered its sApp config, plus storage-profile machines that host unclaimed strands as
276
+ * storage replicas (cadre-core's `CadreNodeConfig.hostUnclaimedStrands`) and whose strand filter
277
+ * admits it. Phones serve only what their app claims, so reusing the party count over-declares, which is worse than
278
+ * declaring nothing, because at a yardstick of three or more the corroboration floor is pinned
279
+ * at two peers and a strand that can field only one could then never repair at all. So every
280
+ * strand runs {@link STRAND_CLUSTER_POLICY} unchanged and takes the single-voter exposure
281
+ * whenever its cohort view shrinks to one peer. The underlying Optimystic behaviour is unfixed
282
+ * (`backlog/debt-read-repair-single-voter-corroboration`) and the per-strand serving count that
283
+ * would let a strand declare honestly is `backlog/feat-strand-yardstick-from-serving-machines`
284
+ * — its seam ({@link strandClusterPolicy}'s `servingMachines`, threaded through
285
+ * `StartStrandConfig.servingMachines`) is already in place and fed by nothing. Raising this
286
+ * number does not buy the way out; a machine, or an honest declaration of the machines actually
287
+ * serving the strand, does.
288
+ *
289
+ * **Why not derived from the party or member count.** The strand's `Member` rows live *in* the
290
+ * strand database, which runs on the strand libp2p node, whose cluster size is frozen at
291
+ * construction — reading the list to configure the node needs the node to exist. Open strands
292
+ * have no member list at all. The count also changes in the unsafe direction: a node that
293
+ * restarted after someone joined derives a wider expected cohort than one that did not, and the
294
+ * membership admission gate's confident path is what rejects the *wider* view. And a `Member` is
295
+ * a party, not a machine; cohort width consumes machines. Full reasoning in
296
+ * `docs/architecture.md` → "Replication cluster size".
297
+ *
298
+ * **Four machines, not four parties.** This number counts machines, and today that is not the
299
+ * same as counting independent holders. Cohort selection upstream is owner-blind — it
300
+ * picks nodes by hash proximity with no concept of which peer belongs to which party
301
+ * (`optimystic/tickets/backlog/feat-cohort-selection-owner-aware-placement`) — and on the
302
+ * cadre-driven path, the one that resolves a seed for itself, a strand's libp2p mesh is seeded
303
+ * from exactly one party's own control-database peer rows (`CadreNode.resolveCohortSeed`), so
304
+ * the cohort actually formed is that one party's machines. Four copies are, on that path, four
305
+ * machines belonging to one party — one failure domain, one witness. A cross-party mesh has
306
+ * exactly one automatic path — a formation result carries the responder's strand addresses and
307
+ * `CadreNode.resolveCohortSeed` unions them in, seeding only the pair that just redeemed an
308
+ * invitation — and is otherwise hand-supplied addresses
309
+ * (`StrandConnectionOptions.bootstrapNodes`) or a hand-written dial (see the doc). Every party
310
+ * now holds its own membership identity, so the missing piece is placement, not identity:
311
+ * tracked as `backlog/feat-strand-party-diverse-placement`. Full reasoning:
312
+ * `docs/architecture.md` → "Replication cluster size".
313
+ *
314
+ * Raising or lowering it per strand is still the embedder's call
315
+ * ({@link resolveStrandClusterSize}, `CadreNodeConfig.strandClusterSize`,
316
+ * `StrandConnectionOptions.clusterSize`) — but every node on one strand must agree, so a change
317
+ * means restarting all of them. There is no smaller-than-{@link MIN_CLUSTER_SIZE} option.
318
+ *
319
+ * NOTE: cohort selection overfetches proportionally — `Libp2pKeyPeerNetwork.membershipOverfetch`
320
+ * asks FRET for `max(clusterSize * 4, clusterSize + 16)` candidates and does one peerStore
321
+ * protocol lookup per candidate, so 4 requests a 20-peer band (was 18 at breadth 2). Negligible,
322
+ * and bounded by the peers FRET actually knows. If strand cohort selection ever shows up as slow,
323
+ * look here first.
324
+ */
325
+ export const DEFAULT_STRAND_CLUSTER_SIZE = 4;
326
+
327
+ /**
328
+ * Cluster consensus policy every STRAND-network libp2p node runs — production
329
+ * (`StrandInstanceManager.buildStrandRuntime`) and the plugin's own networked e2e mesh alike.
330
+ * One definition so the two cannot drift; the literal used to be hand-copied at both sites.
331
+ *
332
+ * Structurally identical to {@link CONTROL_CLUSTER_POLICY} and deliberately separate from it:
333
+ * that one is the control database's, whose breadth is fixed at
334
+ * {@link CONTROL_REPLICATION_BREADTH} for reasons that do not apply to application data.
335
+ *
336
+ * `superMajorityThreshold` is ABSENT for the same reason it is absent there: omitting it is how
337
+ * both the coordinator and the cluster member select Optimystic's
338
+ * `DEFAULT_SUPER_MAJORITY_THRESHOLD` (0.75), the bar {@link DEFAULT_STRAND_CLUSTER_SIZE}'s
339
+ * copies/approvals table is computed against.
340
+ *
341
+ * `sizeTolerance` only makes sense alongside `allowDownsize`: a {@link
342
+ * DEFAULT_STRAND_CLUSTER_SIZE}-wide target is unsatisfiable by a strand of one, two or three
343
+ * machines, so the cohort must be allowed to shrink to the mesh that actually exists.
344
+ *
345
+ * `assumedClusterSize` is REQUIRED, and {@link MIN_CLUSTER_SIZE} is not a guess — it is the
346
+ * smallest strand that reaches the cluster path at all. Absent this field the read-repair /
347
+ * reconcile corroboration floor falls back to `clusterSize`, so
348
+ * `corroboratorCapacity(peers, 4)` is `max(peers, 3)` and two distinct non-self corroborators
349
+ * become mandatory. A two-machine strand fields exactly one peer, can never reach two, and so
350
+ * can never repair a block — `cluster-fetch:no-quorum` forever, which is a read that never
351
+ * catches up and a `create table` that dies with `Missing block` on the joining node. Declaring
352
+ * the honest floor does NOT lower the replication factor (that stays at
353
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE}) and does not relax anything a strand with three or more
354
+ * machines does: `corroboratorCapacity` takes the MAX of the cohort actually present and this
355
+ * number, so the relaxed branch is reachable only by a cohort that is genuinely that small. See
356
+ * Optimystic's `cluster/cluster-policy.ts` ("Why two size yardsticks, not one").
357
+ *
358
+ * `assumedClusterSize` also carries the ADMISSION yardstick, and on the strand path it carries
359
+ * BOTH jobs today. Optimystic prefers a separately declared
360
+ * `repairCorroborationClusterSize` for the repair floor and falls back to this field only when
361
+ * nothing was declared — which, for a strand, is always: no per-strand serving count exists yet,
362
+ * so {@link strandClusterPolicy} declares nothing and the 2 here governs the repair floor
363
+ * exactly as the paragraph above describes, alongside the membership admission gate's
364
+ * low-confidence fallback. Do not raise it if a strand yardstick ever does get declared
365
+ * (`backlog/feat-strand-yardstick-from-serving-machines`): a strand shared between phones cannot
366
+ * promise that `ceil(0.75 x N)` of its machines are awake, and the admission gate would refuse
367
+ * its writes. Same reason and same warning as {@link CONTROL_CLUSTER_POLICY}'s.
368
+ *
369
+ * `cohortQueryTimeoutMs` is {@link COHORT_READ_DEADLINE_MS}, the same value the control policy
370
+ * declares. That is the one field the two networks deliberately agree on by argument rather than
371
+ * by coincidence: it is a property of the LINK, and a phone's control node and its strand nodes
372
+ * ride the same one.
373
+ *
374
+ * The `satisfies` is load-bearing for the same reason as {@link CONTROL_CLUSTER_POLICY}'s.
375
+ */
376
+ export const STRAND_CLUSTER_POLICY = Object.freeze({
377
+ allowDownsize: true,
378
+ sizeTolerance: 0.5,
379
+ assumedClusterSize: MIN_CLUSTER_SIZE,
380
+ cohortQueryTimeoutMs: COHORT_READ_DEADLINE_MS,
381
+ } satisfies NonNullable<NodeOptions['clusterPolicy']>);
382
+
383
+ /**
384
+ * Resolve the cluster size to hand `createLibp2pNode` for a **strand** network, applying
385
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} and rejecting values Optimystic cannot honour. The
386
+ * control network does not route through here — it uses the fixed
387
+ * {@link CONTROL_REPLICATION_BREADTH}.
388
+ *
389
+ * Leaving the value unset is NOT the same as passing the default: Optimystic's own fallback
390
+ * is 10, so every strand node-creating path must route through here.
391
+ *
392
+ * Every node on the same strand should resolve to the same value: the membership admission
393
+ * gate's *confident* path compares a coordinator's declared set against the member's own
394
+ * cohort view, and that view is bounded by this number, so a member configured much higher
395
+ * can reject a smaller declared set as a downsize. Divergence is a live hazard whenever FRET
396
+ * has a confident network-size estimate — not the unconditional refusal earlier revisions of
397
+ * this comment described. See `docs/architecture.md` → "Replication cluster size".
398
+ */
399
+ export function resolveStrandClusterSize(configured?: number): number {
400
+ if (configured === undefined) {
401
+ return DEFAULT_STRAND_CLUSTER_SIZE;
402
+ }
403
+ if (!Number.isInteger(configured) || configured < MIN_CLUSTER_SIZE) {
404
+ throw new Error(
405
+ `clusterSize must be an integer >= ${MIN_CLUSTER_SIZE} (Optimystic's minimum cluster size); got ${configured}`
406
+ );
407
+ }
408
+ return configured;
409
+ }
410
+
411
+ /**
412
+ * The repair yardstick to declare for a network, from the machines that SERVE that network and
413
+ * the breadth it replicates to.
414
+ *
415
+ * ## What this number does
416
+ *
417
+ * Optimystic's block repair (read-repair and reconcile) asks a block's cohort for the newest
418
+ * revision and trusts an answer only when enough independent peers agree on it. "Enough" is
419
+ * measured against a **declared** number rather than the peers currently visible — deliberately,
420
+ * because the visible set comes from unauthenticated routing, and a partition (or an attacker
421
+ * with routing influence) can shrink it. `clusterPolicy.repairCorroborationClusterSize` is that
422
+ * declaration; it moves the repair yardstick ALONE, leaving
423
+ * {@link CONTROL_CLUSTER_POLICY}'s / {@link STRAND_CLUSTER_POLICY}'s `assumedClusterSize` — the
424
+ * membership admission gate's yardstick — untouched. Optimystic resolves
425
+ * `repairCorroborationClusterSize -> assumedClusterSize -> clusterSize`, and a value that is not
426
+ * a positive integer falls THROUGH to the next term rather than being clamped, which is why the
427
+ * builders below refuse to pass a degenerate count at all.
428
+ *
429
+ * ## The arithmetic, and why the formula is what it is
430
+ *
431
+ * Three consumers read the resolved yardstick `N`. With `p` = cohort peers currently visible
432
+ * (self excluded) and Optimystic's `CORROBORATION_FLOOR` = 2:
433
+ *
434
+ * - **Repair corroboration floor** (`corroboratorCapacity` / `quorumSize` in
435
+ * `db-p2p/src/cluster/quorum-restore.ts`): capacity is `max(p, N - 1)` and the floor is
436
+ * `max(1, min(2, capacity))`. So `N = 1` and `N = 2` behave IDENTICALLY at every `p` — both
437
+ * leave the floor at a single voter whenever `p <= 1`. `N >= 3` pins the floor at two
438
+ * corroborators unconditionally, including when the view has shrunk to one peer. That is the
439
+ * entire point of declaring. `N` above 3 raises nothing further (the `min(2, ...)` caps it), so
440
+ * a larger declaration buys only honesty, never more repair.
441
+ * - **Commit freshness window** (`CoordinatorRepo.commitQuorumRulesOutRivals`): `fullCohortSize`
442
+ * is `max(observed cohort, N)`, and a local commit arms the lazy read-repair window only when
443
+ * `approvals > fullCohortSize / 2`. Here a larger `N` is NOT free — see the NOTE below.
444
+ * - The `repair-fault-tolerance` startup advisory — log text only.
445
+ *
446
+ * Hence `max(MIN_CLUSTER_SIZE, min(servingMachines, replicationBreadth))`, two clamps each for
447
+ * a stated reason:
448
+ *
449
+ * - **Capped at the replication breadth**, because a block only ever lives on
450
+ * `min(breadth, machines serving)` machines. A party of eight running strands at
451
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} has a cohort of four; declaring eight would make
452
+ * `approvals > 4` unsatisfiable (a super-majority of four is three), so the freshness window
453
+ * would never arm for any strand commit, forever — and buy nothing on repair, whose floor is
454
+ * already at its cap at four. Declare the machines that can hold the block, not the machines
455
+ * that exist.
456
+ * - **Floored at {@link MIN_CLUSTER_SIZE}**, so this can only ever RAISE the declared number
457
+ * relative to the un-derived constants. A node that authorizes nobody is a genuine
458
+ * founder-alone party, but it is equally a freshly seeded node whose membership rows have not
459
+ * replicated yet, or one whose trusted-owner anchor is empty — and this node cannot tell those
460
+ * apart. Declaring 1 is safety-neutral for repair (identical to 2 at every `p`, per the table
461
+ * above) but it WOULD let a solo commit arm the freshness window, which is wrong if the "solo"
462
+ * reading came from stale local records. Lifting that floor for a node that can prove it is
463
+ * genuinely alone is separate work: `backlog/feat-solo-node-arms-its-own-freshness-window`.
464
+ *
465
+ * The yardstick is per-node and protects only that node's own reads — no node refuses another
466
+ * anything over it — so two members disagreeing (one has replicated a new `CadrePeer` row, one
467
+ * has not) is harmless by construction, not a race to close.
468
+ *
469
+ * ## The contract: callers pass machines that SERVE this network, or nothing at all
470
+ *
471
+ * Over-declaring is not merely wasteful, it is unsafe in the availability direction: at
472
+ * `N >= 3` the corroboration floor is pinned at two peers, so a cohort that can only ever
473
+ * field one peer can never repair at all (`cluster-fetch:no-quorum`), where a declaration of
474
+ * 2 would have let its single peer answer. So the number a caller passes must be the machines
475
+ * that actually serve the network in question — never a broader population that merely bounds
476
+ * it from above, and never a guess. A caller with no trustworthy count passes nothing, which
477
+ * the builders below turn into "declare no yardstick at all".
478
+ *
479
+ * The CONTROL network satisfies this from the party's enrolled machines, because every enrolled
480
+ * machine runs the control node by construction: there, enrolled IS serving
481
+ * ({@link controlClusterPolicy}, fed by cadre-core's `enrolled-machine-store.ts`). A STRAND does
482
+ * not — it launches only on machines whose embedder registered its sApp config
483
+ * (`CadreNode.addStrand`), plus storage-profile machines hosting it as a storage replica
484
+ * (`CadreNodeConfig.hostUnclaimedStrands`) whose strand filter admits it. A closed strand
485
+ * claimed by two phones of a three-phone party is served by two, and declaring three would be
486
+ * exactly the unsafe over-declaration above. No
487
+ * authenticated per-strand serving count exists yet, so strand nodes declare NOTHING and run the
488
+ * frozen {@link STRAND_CLUSTER_POLICY} — keeping the known, upstream-tracked single-voter
489
+ * exposure (`backlog/debt-read-repair-single-voter-corroboration`) rather than risking the
490
+ * cannot-repair-at-all failure. Building that count:
491
+ * `backlog/feat-strand-yardstick-from-serving-machines`.
492
+ *
493
+ * Both arguments must be positive integers; the builders below sanitize before calling, and
494
+ * a degenerate value here propagates (`NaN` in, `NaN` out) rather than being clamped.
495
+ *
496
+ * NOTE: on a party of three or more machines where fewer than half are awake, a commit reaches a
497
+ * downsized cohort and no longer arms the freshness window, so each such block's next read costs
498
+ * one cohort consult per read-repair window instead of trusting the local commit. That is the
499
+ * correct reading of the honest number — a commit that reached two of five machines genuinely
500
+ * does not rule out a rival quorum, and arming it today is a consequence of under-declaring — and
501
+ * the cost is bounded at one consult per block per window by the read path's solo-self-skip exit.
502
+ * NOT measured under load. If a large, mostly-hibernating party's read path ever shows up as
503
+ * slow, this is the first thing to look at.
504
+ */
505
+ export function resolveRepairYardstick(servingMachines: number, replicationBreadth: number): number {
506
+ return Math.max(MIN_CLUSTER_SIZE, Math.min(servingMachines, replicationBreadth));
507
+ }
508
+
509
+ /**
510
+ * A serving-machine count as a usable number, or `undefined` for "this node does not know".
511
+ * Anything that is not a positive integer is unknown rather than clamped: Optimystic itself
512
+ * treats a degenerate declaration as absent, so silently rounding one here would hide a caller
513
+ * bug behind a number nobody chose.
514
+ */
515
+ function asKnownMachineCount(servingMachines?: number): number | undefined {
516
+ return servingMachines !== undefined
517
+ && Number.isInteger(servingMachines)
518
+ && servingMachines >= 1
519
+ ? servingMachines
520
+ : undefined;
521
+ }
522
+
523
+ /**
524
+ * The per-network declarations `controlClusterPolicy` and `strandClusterPolicy` accept, reduced to
525
+ * the two things they have in common. Named rather than positional at the public builders because
526
+ * every one of these is a `number` and they mean unrelated things: a machine COUNT and a
527
+ * millisecond DURATION transposed would compile, and would put a 5 ms read deadline or a yardstick
528
+ * of 5000 on a real node. A third number is already queued for these builders
529
+ * (`backlog/feat-strand-yardstick-from-serving-machines`).
530
+ */
531
+ interface PolicyDeclarations {
532
+ /** The frozen base policy to declare onto. */
533
+ base: NonNullable<NodeOptions['clusterPolicy']>;
534
+ /** Ceiling on the repair yardstick — a block never lives on more machines than the cohort is wide. */
535
+ yardstickCap: number;
536
+ /** Machines serving this network; a degenerate value is treated as absent. */
537
+ machines: number | undefined;
538
+ /** The host's per-peer read deadline in place of {@link COHORT_READ_DEADLINE_MS}. */
539
+ cohortQueryTimeoutMs: number | undefined;
540
+ }
541
+
542
+ /**
543
+ * The base policy with whatever was declared onto it — or the base object ITSELF, by identity,
544
+ * when nothing was, which is the production path for both networks today and so is provably
545
+ * byte-for-byte the old behaviour.
546
+ *
547
+ * The two spreads are independent, so neither declaration can shadow the other: they arrive from
548
+ * unrelated sources (a node-local machine record, the host's network config) and either may be
549
+ * present alone.
550
+ *
551
+ * `cohortQueryTimeoutMs` is passed through UNVALIDATED, by design. Optimystic already refuses a
552
+ * value that is not a finite number above zero, or is above its `MAX_COHORT_QUERY_TIMEOUT_MS`, and
553
+ * it THROWS where the libp2p node is constructed rather than falling back to its own default — so a
554
+ * second check here would only duplicate a message the host already gets, at a site that cannot
555
+ * name the node being built.
556
+ */
557
+ function declarePolicy(
558
+ { base, yardstickCap, machines, cohortQueryTimeoutMs }: PolicyDeclarations
559
+ ): NonNullable<NodeOptions['clusterPolicy']> {
560
+ const known = asKnownMachineCount(machines);
561
+ if (known === undefined && cohortQueryTimeoutMs === undefined) {
562
+ return base;
563
+ }
564
+ return Object.freeze({
565
+ ...base,
566
+ ...(known !== undefined && {
567
+ repairCorroborationClusterSize: resolveRepairYardstick(known, yardstickCap)
568
+ }),
569
+ ...(cohortQueryTimeoutMs !== undefined && { cohortQueryTimeoutMs })
570
+ } satisfies NonNullable<NodeOptions['clusterPolicy']>);
571
+ }
572
+
573
+ /**
574
+ * {@link CONTROL_CLUSTER_POLICY} with the block-repair corroboration yardstick declared from
575
+ * `enrolledMachines` (see {@link resolveRepairYardstick}) and the per-peer read deadline replaced
576
+ * by the host's `cohortQueryTimeoutMs`; the frozen base object ITSELF when neither is declared, so
577
+ * identity assertions keep holding — including for the host that passes a `network` block with
578
+ * neither field in it, whose declarations arrive present-but-`undefined`.
579
+ *
580
+ * The count is named `enrolledMachines` here, unlike {@link strandClusterPolicy}'s, because for the
581
+ * CONTROL network the two quantities are one and the same by construction: every enrolled machine
582
+ * runs the control node, so the machines enrolled in the party ARE the machines serving this
583
+ * network.
584
+ */
585
+ export function controlClusterPolicy(
586
+ declared: { enrolledMachines?: number; cohortQueryTimeoutMs?: number } = {}
587
+ ): NonNullable<NodeOptions['clusterPolicy']> {
588
+ return declarePolicy({
589
+ base: CONTROL_CLUSTER_POLICY,
590
+ yardstickCap: CONTROL_REPLICATION_BREADTH,
591
+ machines: declared.enrolledMachines,
592
+ cohortQueryTimeoutMs: declared.cohortQueryTimeoutMs
593
+ });
594
+ }
595
+
596
+ /**
597
+ * {@link STRAND_CLUSTER_POLICY} with the block-repair corroboration yardstick declared from
598
+ * `servingMachines` — the machines that serve THIS strand — capped at this strand's own
599
+ * `clusterSize` (already resolved by {@link resolveStrandClusterSize}, so it is at least
600
+ * {@link MIN_CLUSTER_SIZE} and the formula's two clamps can never cross), and the per-peer read
601
+ * deadline replaced by the host's `cohortQueryTimeoutMs`; the frozen base object ITSELF when
602
+ * neither is declared.
603
+ *
604
+ * **The no-count path is the production path today.** No authenticated per-strand serving count
605
+ * exists, and the party's enrolled-machine count is emphatically not one (see
606
+ * {@link resolveRepairYardstick}'s contract section), so cadre-core declares no count and every
607
+ * strand node of a host that declared no deadline runs the frozen constant. The field and its
608
+ * threading through `StartStrandConfig.servingMachines` stay in place as the seam that
609
+ * `backlog/feat-strand-yardstick-from-serving-machines` plugs a real count into.
610
+ */
611
+ export function strandClusterPolicy(
612
+ clusterSize: number,
613
+ declared: { servingMachines?: number; cohortQueryTimeoutMs?: number } = {}
614
+ ): NonNullable<NodeOptions['clusterPolicy']> {
615
+ return declarePolicy({
616
+ base: STRAND_CLUSTER_POLICY,
617
+ yardstickCap: clusterSize,
618
+ machines: declared.servingMachines,
619
+ cohortQueryTimeoutMs: declared.cohortQueryTimeoutMs
620
+ });
621
+ }