@serfab/quereus-plugin-sereus 0.12.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import type { NodeOptions } from '@optimystic/db-p2p';
1
2
  /**
2
3
  * Smallest cluster size Optimystic will honour — its own `minAbsoluteClusterSize`,
3
4
  * and the smallest value that reaches the cluster path at all (a lone node writes to
@@ -7,7 +8,12 @@ export declare const MIN_CLUSTER_SIZE = 2;
7
8
  /**
8
9
  * How many nodes each block of the **control** (cadre membership) database is replicated
9
10
  * to. Chosen to exceed any party's node count, so in practice every control block lands
10
- * on every member of the party.
11
+ * on every member of the party. Cohort width alone only delivers that for blocks written
12
+ * while the members were serving the network: a block committed while the writer was
13
+ * alone (the founder's genesis above all — collection headers are written once and never
14
+ * revised) has a cohort of one, and it reaches later joiners through cadre-core's
15
+ * peer-join block catch-up (`peer-join-backfill.ts`, membership-gated on the control
16
+ * network), not through this breadth.
11
17
  *
12
18
  * **Why full replication here.** Every control node reads the whole control database —
13
19
  * membership, peer addresses, the strand list — so a member left out of a block's cohort is
@@ -90,6 +96,20 @@ export declare const CONTROL_REPLICATION_BREADTH = 16;
90
96
  * is what the corroboration floor is asking. See Optimystic's `cluster/cluster-policy.ts`
91
97
  * ("Why two size yardsticks, not one") and `corroboratorCapacity` in `cluster/quorum-restore.ts`.
92
98
  *
99
+ * **`assumedClusterSize` is now the ADMISSION yardstick.** Since Optimystic grew
100
+ * `repairCorroborationClusterSize` (see {@link controlClusterPolicy}), the repair corroboration
101
+ * floor prefers that separate declaration and reads this field only when nothing was declared —
102
+ * the unknown-machine-count path, where the paragraph above still describes what it does. So on
103
+ * every path where the count IS known, the 2 here governs one consumer: the membership admission
104
+ * gate's low-confidence fallback path. Leaving
105
+ * it at 2 is deliberate and must NOT be "fixed" to match the repair yardstick. Raising it would
106
+ * make the gate demand `ceil(0.75 x N)` DECLARED peers before it admits a coordinator's peer
107
+ * set on that path, and a Cadre party is phones and laptops: it cannot promise that three
108
+ * quarters of its enrolled machines are awake at any moment, so a party of eight with three
109
+ * awake would stop transacting entirely. The repair yardstick has no such cost (it gates only
110
+ * whether this node believes a repair answer), which is exactly why the two are now separate
111
+ * numbers rather than one.
112
+ *
93
113
  * The `satisfies` is load-bearing: without it a mistyped or obsolete key would compile at both
94
114
  * this definition and every consumer, because TypeScript only excess-property-checks fresh
95
115
  * object literals, not a shared constant handed to `clusterPolicy`.
@@ -125,6 +145,13 @@ export declare const CONTROL_CLUSTER_POLICY: Readonly<{
125
145
  * phones and laptops is the ordinary case, not the rare one. Six buys no more fault tolerance
126
146
  * than four and costs more overfetch (see the NOTE below).
127
147
  *
148
+ * **Exercised at four machines** by `integration-tests`'
149
+ * `scenarios/strand-two-party-two-machine.integration.ts`, the only scenario that runs a
150
+ * strand at this breadth. It shows a write committing while one of the four machines is off.
151
+ * It does NOT distinguish that from a commit on a cohort downsized to the three live holders
152
+ * (`allowDownsize` in {@link STRAND_CLUSTER_POLICY}), so the table row above remains reasoned
153
+ * arithmetic with an end-to-end existence proof under it, not a measurement of cohort width.
154
+ *
128
155
  * **It is also a correctness floor, not only a durability one — but the floor is measured in
129
156
  * machines, not in this number.** A node that has fallen behind and can ask exactly one peer
130
157
  * whether it is current has Optimystic accept that single answer as the cluster's truth
@@ -133,12 +160,24 @@ export declare const CONTROL_CLUSTER_POLICY: Readonly<{
133
160
  * honestly reports the stale revision, the reader concludes it is current, and re-arms its
134
161
  * repair window forever. Measured on the control-DB replication scenario: 4 failures in 10 runs
135
162
  * at breadth 2, 0 in 20 at breadth 3, 0 in 10 at breadth 8. What lifts the floor off a single
136
- * voter is a *third machine*, not a wider target: `corroboratorCapacity` takes the cohort peers
137
- * actually present against {@link STRAND_CLUSTER_POLICY}'s declared `assumedClusterSize`, so a
138
- * three-machine strand needs two corroborators whatever this number says, and a two-machine one
139
- * cannot supply them however wide the target is. The underlying Optimystic behaviour is unfixed
140
- * — `backlog/debt-read-repair-single-voter-corroboration` — so a two-machine strand still takes
141
- * the exposure. Raising this number does not buy the way out; adding a machine does.
163
+ * voter is a third machine **or a declaration that there are three** — not a wider target.
164
+ * `corroboratorCapacity` takes the max of the cohort peers actually present and the declared
165
+ * repair yardstick, so raising *this* number (the replication breadth) does nothing: the
166
+ * yardstick is a separate declaration, and **a strand node makes no such declaration today**.
167
+ * The control network derives one from the party's enrolled machines
168
+ * ({@link controlClusterPolicy}), which is exact there because every enrolled machine runs the
169
+ * control node. A strand has no equivalent count: it launches only on machines whose embedder
170
+ * registered its sApp config, and reusing the party count over-declares — which is worse than
171
+ * declaring nothing, because at a yardstick of three or more the corroboration floor is pinned
172
+ * at two peers and a strand that can field only one could then never repair at all. So every
173
+ * strand runs {@link STRAND_CLUSTER_POLICY} unchanged and takes the single-voter exposure
174
+ * whenever its cohort view shrinks to one peer. The underlying Optimystic behaviour is unfixed
175
+ * (`backlog/debt-read-repair-single-voter-corroboration`) and the per-strand serving count that
176
+ * would let a strand declare honestly is `backlog/feat-strand-yardstick-from-serving-machines`
177
+ * — its seam ({@link strandClusterPolicy}'s `servingMachines`, threaded through
178
+ * `StartStrandConfig.servingMachines`) is already in place and fed by nothing. Raising this
179
+ * number does not buy the way out; a machine, or an honest declaration of the machines actually
180
+ * serving the strand, does.
142
181
  *
143
182
  * **Why not derived from the party or member count.** The strand's `Member` rows live *in* the
144
183
  * strand database, which runs on the strand libp2p node, whose cluster size is frozen at
@@ -156,11 +195,14 @@ export declare const CONTROL_CLUSTER_POLICY: Readonly<{
156
195
  * cadre-driven path, the one that resolves a seed for itself, a strand's libp2p mesh is seeded
157
196
  * from exactly one party's own control-database peer rows (`CadreNode.resolveCohortSeed`), so
158
197
  * the cohort actually formed is that one party's machines. Four copies are, on that path, four
159
- * machines belonging to one party — one failure domain, one witness. A cross-party mesh is not
160
- * forbidden, only undiscoverable: it takes hand-supplied addresses
161
- * (`StrandConnectionOptions.bootstrapNodes`) or a hand-written dial (see the doc). Tracked:
162
- * `backlog/feat-strand-party-identity`. Full reasoning: `docs/architecture.md` →
163
- * "Replication cluster size".
198
+ * machines belonging to one party — one failure domain, one witness. A cross-party mesh has
199
+ * exactly one automatic path — a formation result carries the responder's strand addresses and
200
+ * `CadreNode.resolveCohortSeed` unions them in, seeding only the pair that just redeemed an
201
+ * invitation — and is otherwise hand-supplied addresses
202
+ * (`StrandConnectionOptions.bootstrapNodes`) or a hand-written dial (see the doc). Every party
203
+ * now holds its own membership identity, so the missing piece is placement, not identity:
204
+ * tracked as `backlog/feat-strand-party-diverse-placement`. Full reasoning:
205
+ * `docs/architecture.md` → "Replication cluster size".
164
206
  *
165
207
  * Raising or lowering it per strand is still the embedder's call
166
208
  * ({@link resolveStrandClusterSize}, `CadreNodeConfig.strandClusterSize`,
@@ -205,6 +247,17 @@ export declare const DEFAULT_STRAND_CLUSTER_SIZE = 4;
205
247
  * number, so the relaxed branch is reachable only by a cohort that is genuinely that small. See
206
248
  * Optimystic's `cluster/cluster-policy.ts` ("Why two size yardsticks, not one").
207
249
  *
250
+ * `assumedClusterSize` also carries the ADMISSION yardstick, and on the strand path it carries
251
+ * BOTH jobs today. Optimystic prefers a separately declared
252
+ * `repairCorroborationClusterSize` for the repair floor and falls back to this field only when
253
+ * nothing was declared — which, for a strand, is always: no per-strand serving count exists yet,
254
+ * so {@link strandClusterPolicy} declares nothing and the 2 here governs the repair floor
255
+ * exactly as the paragraph above describes, alongside the membership admission gate's
256
+ * low-confidence fallback. Do not raise it if a strand yardstick ever does get declared
257
+ * (`backlog/feat-strand-yardstick-from-serving-machines`): a strand shared between phones cannot
258
+ * promise that `ceil(0.75 x N)` of its machines are awake, and the admission gate would refuse
259
+ * its writes. Same reason and same warning as {@link CONTROL_CLUSTER_POLICY}'s.
260
+ *
208
261
  * The `satisfies` is load-bearing for the same reason as {@link CONTROL_CLUSTER_POLICY}'s.
209
262
  */
210
263
  export declare const STRAND_CLUSTER_POLICY: Readonly<{
@@ -229,3 +282,121 @@ export declare const STRAND_CLUSTER_POLICY: Readonly<{
229
282
  * this comment described. See `docs/architecture.md` → "Replication cluster size".
230
283
  */
231
284
  export declare function resolveStrandClusterSize(configured?: number): number;
285
+ /**
286
+ * The repair yardstick to declare for a network, from the machines that SERVE that network and
287
+ * the breadth it replicates to.
288
+ *
289
+ * ## What this number does
290
+ *
291
+ * Optimystic's block repair (read-repair and reconcile) asks a block's cohort for the newest
292
+ * revision and trusts an answer only when enough independent peers agree on it. "Enough" is
293
+ * measured against a **declared** number rather than the peers currently visible — deliberately,
294
+ * because the visible set comes from unauthenticated routing, and a partition (or an attacker
295
+ * with routing influence) can shrink it. `clusterPolicy.repairCorroborationClusterSize` is that
296
+ * declaration; it moves the repair yardstick ALONE, leaving
297
+ * {@link CONTROL_CLUSTER_POLICY}'s / {@link STRAND_CLUSTER_POLICY}'s `assumedClusterSize` — the
298
+ * membership admission gate's yardstick — untouched. Optimystic resolves
299
+ * `repairCorroborationClusterSize -> assumedClusterSize -> clusterSize`, and a value that is not
300
+ * a positive integer falls THROUGH to the next term rather than being clamped, which is why the
301
+ * builders below refuse to pass a degenerate count at all.
302
+ *
303
+ * ## The arithmetic, and why the formula is what it is
304
+ *
305
+ * Three consumers read the resolved yardstick `N`. With `p` = cohort peers currently visible
306
+ * (self excluded) and Optimystic's `CORROBORATION_FLOOR` = 2:
307
+ *
308
+ * - **Repair corroboration floor** (`corroboratorCapacity` / `quorumSize` in
309
+ * `db-p2p/src/cluster/quorum-restore.ts`): capacity is `max(p, N - 1)` and the floor is
310
+ * `max(1, min(2, capacity))`. So `N = 1` and `N = 2` behave IDENTICALLY at every `p` — both
311
+ * leave the floor at a single voter whenever `p <= 1`. `N >= 3` pins the floor at two
312
+ * corroborators unconditionally, including when the view has shrunk to one peer. That is the
313
+ * entire point of declaring. `N` above 3 raises nothing further (the `min(2, ...)` caps it), so
314
+ * a larger declaration buys only honesty, never more repair.
315
+ * - **Commit freshness window** (`CoordinatorRepo.commitQuorumRulesOutRivals`): `fullCohortSize`
316
+ * is `max(observed cohort, N)`, and a local commit arms the lazy read-repair window only when
317
+ * `approvals > fullCohortSize / 2`. Here a larger `N` is NOT free — see the NOTE below.
318
+ * - The `repair-fault-tolerance` startup advisory — log text only.
319
+ *
320
+ * Hence `max(MIN_CLUSTER_SIZE, min(servingMachines, replicationBreadth))`, two clamps each for
321
+ * a stated reason:
322
+ *
323
+ * - **Capped at the replication breadth**, because a block only ever lives on
324
+ * `min(breadth, machines serving)` machines. A party of eight running strands at
325
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} has a cohort of four; declaring eight would make
326
+ * `approvals > 4` unsatisfiable (a super-majority of four is three), so the freshness window
327
+ * would never arm for any strand commit, forever — and buy nothing on repair, whose floor is
328
+ * already at its cap at four. Declare the machines that can hold the block, not the machines
329
+ * that exist.
330
+ * - **Floored at {@link MIN_CLUSTER_SIZE}**, so this can only ever RAISE the declared number
331
+ * relative to the un-derived constants. A node that authorizes nobody is a genuine
332
+ * founder-alone party, but it is equally a freshly seeded node whose membership rows have not
333
+ * replicated yet, or one whose trusted-owner anchor is empty — and this node cannot tell those
334
+ * apart. Declaring 1 is safety-neutral for repair (identical to 2 at every `p`, per the table
335
+ * above) but it WOULD let a solo commit arm the freshness window, which is wrong if the "solo"
336
+ * reading came from stale local records. Lifting that floor for a node that can prove it is
337
+ * genuinely alone is separate work: `backlog/feat-solo-node-arms-its-own-freshness-window`.
338
+ *
339
+ * The yardstick is per-node and protects only that node's own reads — no node refuses another
340
+ * anything over it — so two members disagreeing (one has replicated a new `CadrePeer` row, one
341
+ * has not) is harmless by construction, not a race to close.
342
+ *
343
+ * ## The contract: callers pass machines that SERVE this network, or nothing at all
344
+ *
345
+ * Over-declaring is not merely wasteful, it is unsafe in the availability direction: at
346
+ * `N >= 3` the corroboration floor is pinned at two peers, so a cohort that can only ever
347
+ * field one peer can never repair at all (`cluster-fetch:no-quorum`), where a declaration of
348
+ * 2 would have let its single peer answer. So the number a caller passes must be the machines
349
+ * that actually serve the network in question — never a broader population that merely bounds
350
+ * it from above, and never a guess. A caller with no trustworthy count passes nothing, which
351
+ * the builders below turn into "declare no yardstick at all".
352
+ *
353
+ * The CONTROL network satisfies this from the party's enrolled machines, because every enrolled
354
+ * machine runs the control node by construction: there, enrolled IS serving
355
+ * ({@link controlClusterPolicy}, fed by cadre-core's `enrolled-machine-store.ts`). A STRAND does
356
+ * not — it launches only on machines whose embedder registered its sApp config
357
+ * (`CadreNode.addStrand`), so a closed strand shared by two machines of a three-machine party is
358
+ * served by two, and declaring three would be exactly the unsafe over-declaration above. No
359
+ * authenticated per-strand serving count exists yet, so strand nodes declare NOTHING and run the
360
+ * frozen {@link STRAND_CLUSTER_POLICY} — keeping the known, upstream-tracked single-voter
361
+ * exposure (`backlog/debt-read-repair-single-voter-corroboration`) rather than risking the
362
+ * cannot-repair-at-all failure. Building that count:
363
+ * `backlog/feat-strand-yardstick-from-serving-machines`.
364
+ *
365
+ * Both arguments must be positive integers; the builders below sanitize before calling, and
366
+ * a degenerate value here propagates (`NaN` in, `NaN` out) rather than being clamped.
367
+ *
368
+ * NOTE: on a party of three or more machines where fewer than half are awake, a commit reaches a
369
+ * downsized cohort and no longer arms the freshness window, so each such block's next read costs
370
+ * one cohort consult per read-repair window instead of trusting the local commit. That is the
371
+ * correct reading of the honest number — a commit that reached two of five machines genuinely
372
+ * does not rule out a rival quorum, and arming it today is a consequence of under-declaring — and
373
+ * the cost is bounded at one consult per block per window by the read path's solo-self-skip exit.
374
+ * NOT measured under load. If a large, mostly-hibernating party's read path ever shows up as
375
+ * slow, this is the first thing to look at.
376
+ */
377
+ export declare function resolveRepairYardstick(servingMachines: number, replicationBreadth: number): number;
378
+ /**
379
+ * {@link CONTROL_CLUSTER_POLICY} with the repair yardstick declared from `enrolledMachines`
380
+ * (see {@link resolveRepairYardstick}); the frozen base object ITSELF when the count is unknown,
381
+ * so the cold path is provably today's behaviour and identity assertions keep holding.
382
+ *
383
+ * The parameter keeps the name `enrolledMachines`, unlike {@link strandClusterPolicy}'s, because
384
+ * for the CONTROL network the two quantities are one and the same by construction: every
385
+ * enrolled machine runs the control node, so the machines enrolled in the party ARE the machines
386
+ * serving this network.
387
+ */
388
+ export declare function controlClusterPolicy(enrolledMachines?: number): NonNullable<NodeOptions['clusterPolicy']>;
389
+ /**
390
+ * {@link STRAND_CLUSTER_POLICY} with the repair yardstick declared from `servingMachines` — the
391
+ * machines that serve THIS strand — and this strand's own `clusterSize` (already resolved by
392
+ * {@link resolveStrandClusterSize}, so it is at least {@link MIN_CLUSTER_SIZE} and the formula's
393
+ * two clamps can never cross); the frozen base object ITSELF when the count is unknown.
394
+ *
395
+ * **The unknown path is the production path today.** No authenticated per-strand serving count
396
+ * exists, and the party's enrolled-machine count is emphatically not one (see
397
+ * {@link resolveRepairYardstick}'s contract section), so cadre-core passes nothing and every
398
+ * strand node runs the frozen constant. The parameter and its threading through
399
+ * `StartStrandConfig.servingMachines` stay in place as the seam that
400
+ * `backlog/feat-strand-yardstick-from-serving-machines` plugs a real count into.
401
+ */
402
+ export declare function strandClusterPolicy(clusterSize: number, servingMachines?: number): NonNullable<NodeOptions['clusterPolicy']>;
@@ -7,7 +7,12 @@ export const MIN_CLUSTER_SIZE = 2;
7
7
  /**
8
8
  * How many nodes each block of the **control** (cadre membership) database is replicated
9
9
  * to. Chosen to exceed any party's node count, so in practice every control block lands
10
- * on every member of the party.
10
+ * on every member of the party. Cohort width alone only delivers that for blocks written
11
+ * while the members were serving the network: a block committed while the writer was
12
+ * alone (the founder's genesis above all — collection headers are written once and never
13
+ * revised) has a cohort of one, and it reaches later joiners through cadre-core's
14
+ * peer-join block catch-up (`peer-join-backfill.ts`, membership-gated on the control
15
+ * network), not through this breadth.
11
16
  *
12
17
  * **Why full replication here.** Every control node reads the whole control database —
13
18
  * membership, peer addresses, the strand list — so a member left out of a block's cohort is
@@ -90,6 +95,20 @@ export const CONTROL_REPLICATION_BREADTH = 16;
90
95
  * is what the corroboration floor is asking. See Optimystic's `cluster/cluster-policy.ts`
91
96
  * ("Why two size yardsticks, not one") and `corroboratorCapacity` in `cluster/quorum-restore.ts`.
92
97
  *
98
+ * **`assumedClusterSize` is now the ADMISSION yardstick.** Since Optimystic grew
99
+ * `repairCorroborationClusterSize` (see {@link controlClusterPolicy}), the repair corroboration
100
+ * floor prefers that separate declaration and reads this field only when nothing was declared —
101
+ * the unknown-machine-count path, where the paragraph above still describes what it does. So on
102
+ * every path where the count IS known, the 2 here governs one consumer: the membership admission
103
+ * gate's low-confidence fallback path. Leaving
104
+ * it at 2 is deliberate and must NOT be "fixed" to match the repair yardstick. Raising it would
105
+ * make the gate demand `ceil(0.75 x N)` DECLARED peers before it admits a coordinator's peer
106
+ * set on that path, and a Cadre party is phones and laptops: it cannot promise that three
107
+ * quarters of its enrolled machines are awake at any moment, so a party of eight with three
108
+ * awake would stop transacting entirely. The repair yardstick has no such cost (it gates only
109
+ * whether this node believes a repair answer), which is exactly why the two are now separate
110
+ * numbers rather than one.
111
+ *
93
112
  * The `satisfies` is load-bearing: without it a mistyped or obsolete key would compile at both
94
113
  * this definition and every consumer, because TypeScript only excess-property-checks fresh
95
114
  * object literals, not a shared constant handed to `clusterPolicy`.
@@ -125,6 +144,13 @@ export const CONTROL_CLUSTER_POLICY = Object.freeze({
125
144
  * phones and laptops is the ordinary case, not the rare one. Six buys no more fault tolerance
126
145
  * than four and costs more overfetch (see the NOTE below).
127
146
  *
147
+ * **Exercised at four machines** by `integration-tests`'
148
+ * `scenarios/strand-two-party-two-machine.integration.ts`, the only scenario that runs a
149
+ * strand at this breadth. It shows a write committing while one of the four machines is off.
150
+ * It does NOT distinguish that from a commit on a cohort downsized to the three live holders
151
+ * (`allowDownsize` in {@link STRAND_CLUSTER_POLICY}), so the table row above remains reasoned
152
+ * arithmetic with an end-to-end existence proof under it, not a measurement of cohort width.
153
+ *
128
154
  * **It is also a correctness floor, not only a durability one — but the floor is measured in
129
155
  * machines, not in this number.** A node that has fallen behind and can ask exactly one peer
130
156
  * whether it is current has Optimystic accept that single answer as the cluster's truth
@@ -133,12 +159,24 @@ export const CONTROL_CLUSTER_POLICY = Object.freeze({
133
159
  * honestly reports the stale revision, the reader concludes it is current, and re-arms its
134
160
  * repair window forever. Measured on the control-DB replication scenario: 4 failures in 10 runs
135
161
  * at breadth 2, 0 in 20 at breadth 3, 0 in 10 at breadth 8. What lifts the floor off a single
136
- * voter is a *third machine*, not a wider target: `corroboratorCapacity` takes the cohort peers
137
- * actually present against {@link STRAND_CLUSTER_POLICY}'s declared `assumedClusterSize`, so a
138
- * three-machine strand needs two corroborators whatever this number says, and a two-machine one
139
- * cannot supply them however wide the target is. The underlying Optimystic behaviour is unfixed
140
- * — `backlog/debt-read-repair-single-voter-corroboration` — so a two-machine strand still takes
141
- * the exposure. Raising this number does not buy the way out; adding a machine does.
162
+ * voter is a third machine **or a declaration that there are three** — not a wider target.
163
+ * `corroboratorCapacity` takes the max of the cohort peers actually present and the declared
164
+ * repair yardstick, so raising *this* number (the replication breadth) does nothing: the
165
+ * yardstick is a separate declaration, and **a strand node makes no such declaration today**.
166
+ * The control network derives one from the party's enrolled machines
167
+ * ({@link controlClusterPolicy}), which is exact there because every enrolled machine runs the
168
+ * control node. A strand has no equivalent count: it launches only on machines whose embedder
169
+ * registered its sApp config, and reusing the party count over-declares — which is worse than
170
+ * declaring nothing, because at a yardstick of three or more the corroboration floor is pinned
171
+ * at two peers and a strand that can field only one could then never repair at all. So every
172
+ * strand runs {@link STRAND_CLUSTER_POLICY} unchanged and takes the single-voter exposure
173
+ * whenever its cohort view shrinks to one peer. The underlying Optimystic behaviour is unfixed
174
+ * (`backlog/debt-read-repair-single-voter-corroboration`) and the per-strand serving count that
175
+ * would let a strand declare honestly is `backlog/feat-strand-yardstick-from-serving-machines`
176
+ * — its seam ({@link strandClusterPolicy}'s `servingMachines`, threaded through
177
+ * `StartStrandConfig.servingMachines`) is already in place and fed by nothing. Raising this
178
+ * number does not buy the way out; a machine, or an honest declaration of the machines actually
179
+ * serving the strand, does.
142
180
  *
143
181
  * **Why not derived from the party or member count.** The strand's `Member` rows live *in* the
144
182
  * strand database, which runs on the strand libp2p node, whose cluster size is frozen at
@@ -156,11 +194,14 @@ export const CONTROL_CLUSTER_POLICY = Object.freeze({
156
194
  * cadre-driven path, the one that resolves a seed for itself, a strand's libp2p mesh is seeded
157
195
  * from exactly one party's own control-database peer rows (`CadreNode.resolveCohortSeed`), so
158
196
  * the cohort actually formed is that one party's machines. Four copies are, on that path, four
159
- * machines belonging to one party — one failure domain, one witness. A cross-party mesh is not
160
- * forbidden, only undiscoverable: it takes hand-supplied addresses
161
- * (`StrandConnectionOptions.bootstrapNodes`) or a hand-written dial (see the doc). Tracked:
162
- * `backlog/feat-strand-party-identity`. Full reasoning: `docs/architecture.md` →
163
- * "Replication cluster size".
197
+ * machines belonging to one party — one failure domain, one witness. A cross-party mesh has
198
+ * exactly one automatic path — a formation result carries the responder's strand addresses and
199
+ * `CadreNode.resolveCohortSeed` unions them in, seeding only the pair that just redeemed an
200
+ * invitation — and is otherwise hand-supplied addresses
201
+ * (`StrandConnectionOptions.bootstrapNodes`) or a hand-written dial (see the doc). Every party
202
+ * now holds its own membership identity, so the missing piece is placement, not identity:
203
+ * tracked as `backlog/feat-strand-party-diverse-placement`. Full reasoning:
204
+ * `docs/architecture.md` → "Replication cluster size".
164
205
  *
165
206
  * Raising or lowering it per strand is still the embedder's call
166
207
  * ({@link resolveStrandClusterSize}, `CadreNodeConfig.strandClusterSize`,
@@ -205,6 +246,17 @@ export const DEFAULT_STRAND_CLUSTER_SIZE = 4;
205
246
  * number, so the relaxed branch is reachable only by a cohort that is genuinely that small. See
206
247
  * Optimystic's `cluster/cluster-policy.ts` ("Why two size yardsticks, not one").
207
248
  *
249
+ * `assumedClusterSize` also carries the ADMISSION yardstick, and on the strand path it carries
250
+ * BOTH jobs today. Optimystic prefers a separately declared
251
+ * `repairCorroborationClusterSize` for the repair floor and falls back to this field only when
252
+ * nothing was declared — which, for a strand, is always: no per-strand serving count exists yet,
253
+ * so {@link strandClusterPolicy} declares nothing and the 2 here governs the repair floor
254
+ * exactly as the paragraph above describes, alongside the membership admission gate's
255
+ * low-confidence fallback. Do not raise it if a strand yardstick ever does get declared
256
+ * (`backlog/feat-strand-yardstick-from-serving-machines`): a strand shared between phones cannot
257
+ * promise that `ceil(0.75 x N)` of its machines are awake, and the admission gate would refuse
258
+ * its writes. Same reason and same warning as {@link CONTROL_CLUSTER_POLICY}'s.
259
+ *
208
260
  * The `satisfies` is load-bearing for the same reason as {@link CONTROL_CLUSTER_POLICY}'s.
209
261
  */
210
262
  export const STRAND_CLUSTER_POLICY = Object.freeze({
@@ -237,4 +289,155 @@ export function resolveStrandClusterSize(configured) {
237
289
  }
238
290
  return configured;
239
291
  }
292
+ /**
293
+ * The repair yardstick to declare for a network, from the machines that SERVE that network and
294
+ * the breadth it replicates to.
295
+ *
296
+ * ## What this number does
297
+ *
298
+ * Optimystic's block repair (read-repair and reconcile) asks a block's cohort for the newest
299
+ * revision and trusts an answer only when enough independent peers agree on it. "Enough" is
300
+ * measured against a **declared** number rather than the peers currently visible — deliberately,
301
+ * because the visible set comes from unauthenticated routing, and a partition (or an attacker
302
+ * with routing influence) can shrink it. `clusterPolicy.repairCorroborationClusterSize` is that
303
+ * declaration; it moves the repair yardstick ALONE, leaving
304
+ * {@link CONTROL_CLUSTER_POLICY}'s / {@link STRAND_CLUSTER_POLICY}'s `assumedClusterSize` — the
305
+ * membership admission gate's yardstick — untouched. Optimystic resolves
306
+ * `repairCorroborationClusterSize -> assumedClusterSize -> clusterSize`, and a value that is not
307
+ * a positive integer falls THROUGH to the next term rather than being clamped, which is why the
308
+ * builders below refuse to pass a degenerate count at all.
309
+ *
310
+ * ## The arithmetic, and why the formula is what it is
311
+ *
312
+ * Three consumers read the resolved yardstick `N`. With `p` = cohort peers currently visible
313
+ * (self excluded) and Optimystic's `CORROBORATION_FLOOR` = 2:
314
+ *
315
+ * - **Repair corroboration floor** (`corroboratorCapacity` / `quorumSize` in
316
+ * `db-p2p/src/cluster/quorum-restore.ts`): capacity is `max(p, N - 1)` and the floor is
317
+ * `max(1, min(2, capacity))`. So `N = 1` and `N = 2` behave IDENTICALLY at every `p` — both
318
+ * leave the floor at a single voter whenever `p <= 1`. `N >= 3` pins the floor at two
319
+ * corroborators unconditionally, including when the view has shrunk to one peer. That is the
320
+ * entire point of declaring. `N` above 3 raises nothing further (the `min(2, ...)` caps it), so
321
+ * a larger declaration buys only honesty, never more repair.
322
+ * - **Commit freshness window** (`CoordinatorRepo.commitQuorumRulesOutRivals`): `fullCohortSize`
323
+ * is `max(observed cohort, N)`, and a local commit arms the lazy read-repair window only when
324
+ * `approvals > fullCohortSize / 2`. Here a larger `N` is NOT free — see the NOTE below.
325
+ * - The `repair-fault-tolerance` startup advisory — log text only.
326
+ *
327
+ * Hence `max(MIN_CLUSTER_SIZE, min(servingMachines, replicationBreadth))`, two clamps each for
328
+ * a stated reason:
329
+ *
330
+ * - **Capped at the replication breadth**, because a block only ever lives on
331
+ * `min(breadth, machines serving)` machines. A party of eight running strands at
332
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} has a cohort of four; declaring eight would make
333
+ * `approvals > 4` unsatisfiable (a super-majority of four is three), so the freshness window
334
+ * would never arm for any strand commit, forever — and buy nothing on repair, whose floor is
335
+ * already at its cap at four. Declare the machines that can hold the block, not the machines
336
+ * that exist.
337
+ * - **Floored at {@link MIN_CLUSTER_SIZE}**, so this can only ever RAISE the declared number
338
+ * relative to the un-derived constants. A node that authorizes nobody is a genuine
339
+ * founder-alone party, but it is equally a freshly seeded node whose membership rows have not
340
+ * replicated yet, or one whose trusted-owner anchor is empty — and this node cannot tell those
341
+ * apart. Declaring 1 is safety-neutral for repair (identical to 2 at every `p`, per the table
342
+ * above) but it WOULD let a solo commit arm the freshness window, which is wrong if the "solo"
343
+ * reading came from stale local records. Lifting that floor for a node that can prove it is
344
+ * genuinely alone is separate work: `backlog/feat-solo-node-arms-its-own-freshness-window`.
345
+ *
346
+ * The yardstick is per-node and protects only that node's own reads — no node refuses another
347
+ * anything over it — so two members disagreeing (one has replicated a new `CadrePeer` row, one
348
+ * has not) is harmless by construction, not a race to close.
349
+ *
350
+ * ## The contract: callers pass machines that SERVE this network, or nothing at all
351
+ *
352
+ * Over-declaring is not merely wasteful, it is unsafe in the availability direction: at
353
+ * `N >= 3` the corroboration floor is pinned at two peers, so a cohort that can only ever
354
+ * field one peer can never repair at all (`cluster-fetch:no-quorum`), where a declaration of
355
+ * 2 would have let its single peer answer. So the number a caller passes must be the machines
356
+ * that actually serve the network in question — never a broader population that merely bounds
357
+ * it from above, and never a guess. A caller with no trustworthy count passes nothing, which
358
+ * the builders below turn into "declare no yardstick at all".
359
+ *
360
+ * The CONTROL network satisfies this from the party's enrolled machines, because every enrolled
361
+ * machine runs the control node by construction: there, enrolled IS serving
362
+ * ({@link controlClusterPolicy}, fed by cadre-core's `enrolled-machine-store.ts`). A STRAND does
363
+ * not — it launches only on machines whose embedder registered its sApp config
364
+ * (`CadreNode.addStrand`), so a closed strand shared by two machines of a three-machine party is
365
+ * served by two, and declaring three would be exactly the unsafe over-declaration above. No
366
+ * authenticated per-strand serving count exists yet, so strand nodes declare NOTHING and run the
367
+ * frozen {@link STRAND_CLUSTER_POLICY} — keeping the known, upstream-tracked single-voter
368
+ * exposure (`backlog/debt-read-repair-single-voter-corroboration`) rather than risking the
369
+ * cannot-repair-at-all failure. Building that count:
370
+ * `backlog/feat-strand-yardstick-from-serving-machines`.
371
+ *
372
+ * Both arguments must be positive integers; the builders below sanitize before calling, and
373
+ * a degenerate value here propagates (`NaN` in, `NaN` out) rather than being clamped.
374
+ *
375
+ * NOTE: on a party of three or more machines where fewer than half are awake, a commit reaches a
376
+ * downsized cohort and no longer arms the freshness window, so each such block's next read costs
377
+ * one cohort consult per read-repair window instead of trusting the local commit. That is the
378
+ * correct reading of the honest number — a commit that reached two of five machines genuinely
379
+ * does not rule out a rival quorum, and arming it today is a consequence of under-declaring — and
380
+ * the cost is bounded at one consult per block per window by the read path's solo-self-skip exit.
381
+ * NOT measured under load. If a large, mostly-hibernating party's read path ever shows up as
382
+ * slow, this is the first thing to look at.
383
+ */
384
+ export function resolveRepairYardstick(servingMachines, replicationBreadth) {
385
+ return Math.max(MIN_CLUSTER_SIZE, Math.min(servingMachines, replicationBreadth));
386
+ }
387
+ /**
388
+ * A serving-machine count as a usable number, or `undefined` for "this node does not know".
389
+ * Anything that is not a positive integer is unknown rather than clamped: Optimystic itself
390
+ * treats a degenerate declaration as absent, so silently rounding one here would hide a caller
391
+ * bug behind a number nobody chose.
392
+ */
393
+ function asKnownMachineCount(servingMachines) {
394
+ return servingMachines !== undefined
395
+ && Number.isInteger(servingMachines)
396
+ && servingMachines >= 1
397
+ ? servingMachines
398
+ : undefined;
399
+ }
400
+ /**
401
+ * {@link CONTROL_CLUSTER_POLICY} with the repair yardstick declared from `enrolledMachines`
402
+ * (see {@link resolveRepairYardstick}); the frozen base object ITSELF when the count is unknown,
403
+ * so the cold path is provably today's behaviour and identity assertions keep holding.
404
+ *
405
+ * The parameter keeps the name `enrolledMachines`, unlike {@link strandClusterPolicy}'s, because
406
+ * for the CONTROL network the two quantities are one and the same by construction: every
407
+ * enrolled machine runs the control node, so the machines enrolled in the party ARE the machines
408
+ * serving this network.
409
+ */
410
+ export function controlClusterPolicy(enrolledMachines) {
411
+ const known = asKnownMachineCount(enrolledMachines);
412
+ if (known === undefined) {
413
+ return CONTROL_CLUSTER_POLICY;
414
+ }
415
+ return Object.freeze({
416
+ ...CONTROL_CLUSTER_POLICY,
417
+ repairCorroborationClusterSize: resolveRepairYardstick(known, CONTROL_REPLICATION_BREADTH)
418
+ });
419
+ }
420
+ /**
421
+ * {@link STRAND_CLUSTER_POLICY} with the repair yardstick declared from `servingMachines` — the
422
+ * machines that serve THIS strand — and this strand's own `clusterSize` (already resolved by
423
+ * {@link resolveStrandClusterSize}, so it is at least {@link MIN_CLUSTER_SIZE} and the formula's
424
+ * two clamps can never cross); the frozen base object ITSELF when the count is unknown.
425
+ *
426
+ * **The unknown path is the production path today.** No authenticated per-strand serving count
427
+ * exists, and the party's enrolled-machine count is emphatically not one (see
428
+ * {@link resolveRepairYardstick}'s contract section), so cadre-core passes nothing and every
429
+ * strand node runs the frozen constant. The parameter and its threading through
430
+ * `StartStrandConfig.servingMachines` stay in place as the seam that
431
+ * `backlog/feat-strand-yardstick-from-serving-machines` plugs a real count into.
432
+ */
433
+ export function strandClusterPolicy(clusterSize, servingMachines) {
434
+ const known = asKnownMachineCount(servingMachines);
435
+ if (known === undefined) {
436
+ return STRAND_CLUSTER_POLICY;
437
+ }
438
+ return Object.freeze({
439
+ ...STRAND_CLUSTER_POLICY,
440
+ repairCorroborationClusterSize: resolveRepairYardstick(known, clusterSize)
441
+ });
442
+ }
240
443
  //# sourceMappingURL=cluster-size.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cluster-size.js","sourceRoot":"","sources":["../src/cluster-size.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAElC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,EAAE,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC,MAAM,CAAC;IACnD,aAAa,EAAE,IAAI;IACnB,aAAa,EAAE,GAAG;IAClB,kBAAkB,EAAE,CAAC;CAC+B,CAAC,CAAC;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC,MAAM,CAAC;IAClD,aAAa,EAAE,IAAI;IACnB,aAAa,EAAE,GAAG;IAClB,kBAAkB,EAAE,gBAAgB;CACgB,CAAC,CAAC;AAEvD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,wBAAwB,CAAC,UAAmB;IAC3D,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;QAC9B,OAAO,2BAA2B,CAAC;IACpC,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,gBAAgB,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CACd,qCAAqC,gBAAgB,6CAA6C,UAAU,EAAE,CAC9G,CAAC;IACH,CAAC;IACD,OAAO,UAAU,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"cluster-size.js","sourceRoot":"","sources":["../src/cluster-size.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { connectToStrand } from './connect.js';
2
- export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize } from './cluster-size.js';
2
+ export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize, resolveRepairYardstick, controlClusterPolicy, strandClusterPolicy } from './cluster-size.js';
3
3
  export { wrapStorageWithCache, disposeStorageCache } from './cached-storage.js';
4
4
  export type { StrandConnectionOptions, SereusPluginResult, StrandTransactor } from './types.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { connectToStrand } from './connect.js';
2
- export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize } from './cluster-size.js';
2
+ export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize, resolveRepairYardstick, controlClusterPolicy, strandClusterPolicy } from './cluster-size.js';
3
3
  export { wrapStorageWithCache, disposeStorageCache } from './cached-storage.js';
4
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EACN,gBAAgB,EAChB,2BAA2B,EAC3B,sBAAsB,EACtB,2BAA2B,EAC3B,qBAAqB,EACrB,wBAAwB,EACxB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EACN,gBAAgB,EAChB,2BAA2B,EAC3B,sBAAsB,EACtB,2BAA2B,EAC3B,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC"}