@serfab/quereus-plugin-sereus 0.11.0 → 0.13.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.
@@ -31,6 +31,19 @@
31
31
  * `MemoryRawStorage` is returned unwrapped on upstream's own guidance: the backend
32
32
  * is already in-memory, so the cache adds bookkeeping with nothing to save.
33
33
  *
34
+ * **{@link wrapStorageWithCache} and {@link disposeStorageCache} are a matched pair
35
+ * with a holder count between them.** Every wrap that hands back a cache this module
36
+ * created takes one claim on it; every dispose of that cache releases one. The cache
37
+ * is emptied, unregistered from the shared pool, and forgotten only when the LAST
38
+ * claim goes. That is what makes overlapping scopes safe: `CadreNodeConfig.storage.provider`
39
+ * may be a single `IRawStorage` shared by the control database and every workspace,
40
+ * and without a count the first scope to stop would retire the cache the others are
41
+ * still reading through — then the next scope to start, finding the memo empty, would
42
+ * build a SECOND cache over the same backend, and each would serve its own stale view
43
+ * (including remembered "this block does not exist" answers). It is also what lets
44
+ * `composeStrand` release its own claim unconditionally on `shutdown()` without having
45
+ * to guess whether it or cadre-core created the wrapper.
46
+ *
34
47
  * The memo dedupes *concurrent* seams; it is not what keeps a cache alive across a
35
48
  * runtime rebuild. That is cadre-core's job, and it does it by OWNERSHIP: one
36
49
  * resolved store per scope per runtime lifetime — a strand's store for the life of
@@ -39,28 +52,44 @@
39
52
  * `CadreNode.start()`. Because the store outlives the rebuild, so does its wrap,
40
53
  * whether or not the embedder's provider returns a stable instance.
41
54
  *
42
- * When a scope's runtime stops for good, cadre-core calls {@link disposeStorageCache}
43
- * — the release half of that ownership. Without it the retired wrapper's
44
- * `CacheStoreHandle` would stay in the shared pool's registry for the process
45
- * lifetime (`unregisterStore` is the only removal), one orphan per stop.
55
+ * When a scope's runtime stops for good, it calls {@link disposeStorageCache} — the
56
+ * release half of that ownership. Without it the retired wrapper's `CacheStoreHandle`
57
+ * would stay in the shared pool's registry for the process lifetime (`unregisterStore`
58
+ * is the only removal), one orphan per stop.
59
+ *
60
+ * NOTE: upstream ships an equivalent counted wrap of its own — `withReadCache`, which
61
+ * hands back a `ReadCacheLease` per caller and additionally dedupes by
62
+ * `getStoreIdentity()`, so two DISTINCT instances over one directory share one cache.
63
+ * This module keeps its own count instead, because its public surface releases by
64
+ * WRAPPER (`disposeStorageCache(storage)`) rather than by lease, and `withReadCache`
65
+ * returns no lease on its already-cached pass-through — which would leave
66
+ * `composeStrand`'s unconditional release unable to tell its own claim from cadre-core's.
67
+ * Adopting the lease API would mean changing that surface at all three call sites; worth
68
+ * doing if Sereus ever needs the identity-keyed dedupe (two `FileRawStorage` over one
69
+ * path), and not before.
46
70
  */
47
71
  import { type IRawStorage } from '@optimystic/db-p2p/rn';
48
72
  /**
49
- * The cached view of `storage`, memoized per instance. `label` names the store in
50
- * the shared pool's `stats()` (e.g. `control`, or the strand id).
73
+ * The cached view of `storage`, memoized per instance, with one holder claim taken for
74
+ * this caller — release it with {@link disposeStorageCache} when this caller's scope
75
+ * ends. `label` names the store in the shared pool's `stats()` (e.g. `control`, or the
76
+ * strand id); on a memo hit the first caller's label sticks, since a second cache over
77
+ * one backend is the failure that matters and a stale pool label is not.
51
78
  */
52
79
  export declare function wrapStorageWithCache(storage: IRawStorage, label: string): IRawStorage;
53
80
  /**
54
- * Release the cache wrapper {@link wrapStorageWithCache} returned: unregister it from
55
- * the shared pool and forget the memo, so a later re-wrap of the same inner instance
56
- * gets a live cache rather than this retired one. A disposed wrapper must never be
57
- * handed out again — its pool store id is retired and never reused — and providers
58
- * that return a stable instance per scope (the web reference app's `strand-storage.ts`,
59
- * the integration harness's `captureRawStorage`) would get exactly that back on a
60
- * relaunch if the memo were left in place.
81
+ * Release this caller's claim on the cache wrapper {@link wrapStorageWithCache} returned.
82
+ * The wrapper is emptied, unregistered from the shared pool, and dropped from the memo
83
+ * only when the LAST holder releases — so a scope stopping never retires a cache another
84
+ * live scope is still reading through, and a still-held wrapper is never replaced by a
85
+ * competing second cache over the same backend.
86
+ *
87
+ * A wrapper with no live claim is left alone: that covers both a cache the embedder built
88
+ * itself (theirs to dispose) and a late second dispose of an already-retired wrapper,
89
+ * whose successor in the memo must not be dropped.
61
90
  *
62
- * No-op for anything this module returned unwrapped (e.g. `MemoryRawStorage`), so
63
- * callers need no `instanceof` test. Only the wrapper is released: the inner store the
64
- * embedder returned is never closed — that handle stays the embedder's to manage.
91
+ * No-op for anything this module returned unwrapped (e.g. `MemoryRawStorage`), so callers
92
+ * need no `instanceof` test. Only the wrapper is released: the inner store the embedder
93
+ * returned is never closed — that handle stays the embedder's to manage.
65
94
  */
66
95
  export declare function disposeStorageCache(storage: IRawStorage): Promise<void>;
@@ -31,6 +31,19 @@
31
31
  * `MemoryRawStorage` is returned unwrapped on upstream's own guidance: the backend
32
32
  * is already in-memory, so the cache adds bookkeeping with nothing to save.
33
33
  *
34
+ * **{@link wrapStorageWithCache} and {@link disposeStorageCache} are a matched pair
35
+ * with a holder count between them.** Every wrap that hands back a cache this module
36
+ * created takes one claim on it; every dispose of that cache releases one. The cache
37
+ * is emptied, unregistered from the shared pool, and forgotten only when the LAST
38
+ * claim goes. That is what makes overlapping scopes safe: `CadreNodeConfig.storage.provider`
39
+ * may be a single `IRawStorage` shared by the control database and every workspace,
40
+ * and without a count the first scope to stop would retire the cache the others are
41
+ * still reading through — then the next scope to start, finding the memo empty, would
42
+ * build a SECOND cache over the same backend, and each would serve its own stale view
43
+ * (including remembered "this block does not exist" answers). It is also what lets
44
+ * `composeStrand` release its own claim unconditionally on `shutdown()` without having
45
+ * to guess whether it or cadre-core created the wrapper.
46
+ *
34
47
  * The memo dedupes *concurrent* seams; it is not what keeps a cache alive across a
35
48
  * runtime rebuild. That is cadre-core's job, and it does it by OWNERSHIP: one
36
49
  * resolved store per scope per runtime lifetime — a strand's store for the life of
@@ -39,10 +52,21 @@
39
52
  * `CadreNode.start()`. Because the store outlives the rebuild, so does its wrap,
40
53
  * whether or not the embedder's provider returns a stable instance.
41
54
  *
42
- * When a scope's runtime stops for good, cadre-core calls {@link disposeStorageCache}
43
- * — the release half of that ownership. Without it the retired wrapper's
44
- * `CacheStoreHandle` would stay in the shared pool's registry for the process
45
- * lifetime (`unregisterStore` is the only removal), one orphan per stop.
55
+ * When a scope's runtime stops for good, it calls {@link disposeStorageCache} — the
56
+ * release half of that ownership. Without it the retired wrapper's `CacheStoreHandle`
57
+ * would stay in the shared pool's registry for the process lifetime (`unregisterStore`
58
+ * is the only removal), one orphan per stop.
59
+ *
60
+ * NOTE: upstream ships an equivalent counted wrap of its own — `withReadCache`, which
61
+ * hands back a `ReadCacheLease` per caller and additionally dedupes by
62
+ * `getStoreIdentity()`, so two DISTINCT instances over one directory share one cache.
63
+ * This module keeps its own count instead, because its public surface releases by
64
+ * WRAPPER (`disposeStorageCache(storage)`) rather than by lease, and `withReadCache`
65
+ * returns no lease on its already-cached pass-through — which would leave
66
+ * `composeStrand`'s unconditional release unable to tell its own claim from cadre-core's.
67
+ * Adopting the lease API would mean changing that surface at all three call sites; worth
68
+ * doing if Sereus ever needs the identity-keyed dedupe (two `FileRawStorage` over one
69
+ * path), and not before.
46
70
  */
47
71
  // Imported from the `/rn` entry — the platform-neutral surface (no Node-only
48
72
  // transports) — because `composeStrand` reaches this module on the browser path
@@ -50,54 +74,83 @@
50
74
  // identity (the `instanceof` checks below) is unaffected on Node.
51
75
  import { CachedRawStorage, MemoryRawStorage } from '@optimystic/db-p2p/rn';
52
76
  const wraps = new WeakMap();
77
+ const claims = new WeakMap();
53
78
  /**
54
- * Reverse of `wraps`, so {@link disposeStorageCache} can evict the memo entry it
55
- * cannot otherwise find — `wraps` is keyed by the inner instance, and dispose is
56
- * handed the wrapper.
79
+ * Count one more holder of `wrapped`, if this module created it. A cache built outside
80
+ * this module stays its creator's to release, so it is left uncounted and
81
+ * {@link disposeStorageCache} no-ops on it.
57
82
  */
58
- const inners = new WeakMap();
83
+ function addHolder(wrapped) {
84
+ const claim = claims.get(wrapped);
85
+ if (claim) {
86
+ claim.holders += 1;
87
+ }
88
+ }
59
89
  /**
60
- * The cached view of `storage`, memoized per instance. `label` names the store in
61
- * the shared pool's `stats()` (e.g. `control`, or the strand id).
90
+ * The cached view of `storage`, memoized per instance, with one holder claim taken for
91
+ * this caller — release it with {@link disposeStorageCache} when this caller's scope
92
+ * ends. `label` names the store in the shared pool's `stats()` (e.g. `control`, or the
93
+ * strand id); on a memo hit the first caller's label sticks, since a second cache over
94
+ * one backend is the failure that matters and a stale pool label is not.
62
95
  */
63
96
  export function wrapStorageWithCache(storage, label) {
64
- if (storage instanceof MemoryRawStorage || storage instanceof CachedRawStorage) {
97
+ if (storage instanceof MemoryRawStorage) {
98
+ return storage;
99
+ }
100
+ if (storage instanceof CachedRawStorage) {
101
+ // Already the wrapper this module handed out: cadre-core wraps at its own seams
102
+ // and passes the wrapper down to `composeStrand`, which wraps again. Count the
103
+ // new holder rather than stacking a second cache over one backend.
104
+ // NOTE: a RETIRED wrapper (last holder already released) is also returned as-is,
105
+ // uncounted — the caller gets a dead cache with no error. No call site can reach
106
+ // that today: both retire sites drop their reference in the same block that
107
+ // releases. If a caller ever holds a wrapper across its own release, make this
108
+ // branch throw on an absent claim rather than hand back the corpse.
109
+ addHolder(storage);
65
110
  return storage;
66
111
  }
67
- let wrapped = wraps.get(storage);
68
- if (!wrapped) {
69
- wrapped = new CachedRawStorage(storage, undefined, label);
70
- wraps.set(storage, wrapped);
71
- inners.set(wrapped, storage);
112
+ const memoized = wraps.get(storage);
113
+ if (memoized) {
114
+ addHolder(memoized);
115
+ return memoized;
72
116
  }
117
+ const wrapped = new CachedRawStorage(storage, undefined, label);
118
+ wraps.set(storage, wrapped);
119
+ claims.set(wrapped, { inner: storage, holders: 1 });
73
120
  return wrapped;
74
121
  }
75
122
  /**
76
- * Release the cache wrapper {@link wrapStorageWithCache} returned: unregister it from
77
- * the shared pool and forget the memo, so a later re-wrap of the same inner instance
78
- * gets a live cache rather than this retired one. A disposed wrapper must never be
79
- * handed out again — its pool store id is retired and never reused — and providers
80
- * that return a stable instance per scope (the web reference app's `strand-storage.ts`,
81
- * the integration harness's `captureRawStorage`) would get exactly that back on a
82
- * relaunch if the memo were left in place.
123
+ * Release this caller's claim on the cache wrapper {@link wrapStorageWithCache} returned.
124
+ * The wrapper is emptied, unregistered from the shared pool, and dropped from the memo
125
+ * only when the LAST holder releases — so a scope stopping never retires a cache another
126
+ * live scope is still reading through, and a still-held wrapper is never replaced by a
127
+ * competing second cache over the same backend.
128
+ *
129
+ * A wrapper with no live claim is left alone: that covers both a cache the embedder built
130
+ * itself (theirs to dispose) and a late second dispose of an already-retired wrapper,
131
+ * whose successor in the memo must not be dropped.
83
132
  *
84
- * No-op for anything this module returned unwrapped (e.g. `MemoryRawStorage`), so
85
- * callers need no `instanceof` test. Only the wrapper is released: the inner store the
86
- * embedder returned is never closed — that handle stays the embedder's to manage.
133
+ * No-op for anything this module returned unwrapped (e.g. `MemoryRawStorage`), so callers
134
+ * need no `instanceof` test. Only the wrapper is released: the inner store the embedder
135
+ * returned is never closed — that handle stays the embedder's to manage.
87
136
  */
88
137
  export async function disposeStorageCache(storage) {
89
138
  if (!(storage instanceof CachedRawStorage)) {
90
139
  return;
91
140
  }
92
- const inner = inners.get(storage);
93
- if (inner) {
94
- inners.delete(storage);
95
- // Only evict the memo if it still points at THIS wrapper: a re-wrap after an
96
- // earlier dispose has already installed a live successor we must not drop.
97
- if (wraps.get(inner) === storage) {
98
- wraps.delete(inner);
99
- }
141
+ const claim = claims.get(storage);
142
+ if (!claim) {
143
+ return;
144
+ }
145
+ // Decrement, and on zero retire the bookkeeping, all in this SYNCHRONOUS prefix —
146
+ // before the await below. A wrap of the same inner instance interleaved between the
147
+ // two would otherwise find a memo entry pointing at a dying cache.
148
+ claim.holders -= 1;
149
+ if (claim.holders > 0) {
150
+ return;
100
151
  }
152
+ claims.delete(storage);
153
+ wraps.delete(claim.inner);
101
154
  await storage.dispose();
102
155
  }
103
156
  //# sourceMappingURL=cached-storage.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"cached-storage.js","sourceRoot":"","sources":["../src/cached-storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAEH,6EAA6E;AAC7E,gFAAgF;AAChF,4EAA4E;AAC5E,kEAAkE;AAClE,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAoB,MAAM,uBAAuB,CAAC;AAE7F,MAAM,KAAK,GAAG,IAAI,OAAO,EAAiC,CAAC;AAE3D;;;;GAIG;AACH,MAAM,MAAM,GAAG,IAAI,OAAO,EAAiC,CAAC;AAE5D;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAoB,EAAE,KAAa;IACvE,IAAI,OAAO,YAAY,gBAAgB,IAAI,OAAO,YAAY,gBAAgB,EAAE,CAAC;QAChF,OAAO,OAAO,CAAC;IAChB,CAAC;IACD,IAAI,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACjC,IAAI,CAAC,OAAO,EAAE,CAAC;QACd,OAAO,GAAG,IAAI,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;QAC1D,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC5B,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,OAAO,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,OAAoB;IAC7D,IAAI,CAAC,CAAC,OAAO,YAAY,gBAAgB,CAAC,EAAE,CAAC;QAC5C,OAAO;IACR,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAClC,IAAI,KAAK,EAAE,CAAC;QACX,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACvB,6EAA6E;QAC7E,2EAA2E;QAC3E,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,OAAO,EAAE,CAAC;YAClC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACrB,CAAC;IACF,CAAC;IACD,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"cached-storage.js","sourceRoot":"","sources":["../src/cached-storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AAEH,6EAA6E;AAC7E,gFAAgF;AAChF,4EAA4E;AAC5E,kEAAkE;AAClE,OAAO,EAAE,gBAAgB,EAAE,gBAAgB,EAAoB,MAAM,uBAAuB,CAAC;AAE7F,MAAM,KAAK,GAAG,IAAI,OAAO,EAAiC,CAAC;AAmB3D,MAAM,MAAM,GAAG,IAAI,OAAO,EAAgC,CAAC;AAE3D;;;;GAIG;AACH,SAAS,SAAS,CAAC,OAAyB;IAC3C,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAClC,IAAI,KAAK,EAAE,CAAC;QACX,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;IACpB,CAAC;AACF,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAoB,EAAE,KAAa;IACvE,IAAI,OAAO,YAAY,gBAAgB,EAAE,CAAC;QACzC,OAAO,OAAO,CAAC;IAChB,CAAC;IACD,IAAI,OAAO,YAAY,gBAAgB,EAAE,CAAC;QACzC,gFAAgF;QAChF,+EAA+E;QAC/E,mEAAmE;QACnE,iFAAiF;QACjF,iFAAiF;QACjF,4EAA4E;QAC5E,+EAA+E;QAC/E,oEAAoE;QACpE,SAAS,CAAC,OAAO,CAAC,CAAC;QACnB,OAAO,OAAO,CAAC;IAChB,CAAC;IACD,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACpC,IAAI,QAAQ,EAAE,CAAC;QACd,SAAS,CAAC,QAAQ,CAAC,CAAC;QACpB,OAAO,QAAQ,CAAC;IACjB,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAChE,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC5B,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IACpD,OAAO,OAAO,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,OAAoB;IAC7D,IAAI,CAAC,CAAC,OAAO,YAAY,gBAAgB,CAAC,EAAE,CAAC;QAC5C,OAAO;IACR,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAClC,IAAI,CAAC,KAAK,EAAE,CAAC;QACZ,OAAO;IACR,CAAC;IACD,kFAAkF;IAClF,oFAAoF;IACpF,mEAAmE;IACnE,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC;IACnB,IAAI,KAAK,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;QACvB,OAAO;IACR,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACvB,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC1B,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;AACzB,CAAC"}
@@ -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
@@ -205,6 +244,17 @@ export declare const DEFAULT_STRAND_CLUSTER_SIZE = 4;
205
244
  * number, so the relaxed branch is reachable only by a cohort that is genuinely that small. See
206
245
  * Optimystic's `cluster/cluster-policy.ts` ("Why two size yardsticks, not one").
207
246
  *
247
+ * `assumedClusterSize` also carries the ADMISSION yardstick, and on the strand path it carries
248
+ * BOTH jobs today. Optimystic prefers a separately declared
249
+ * `repairCorroborationClusterSize` for the repair floor and falls back to this field only when
250
+ * nothing was declared — which, for a strand, is always: no per-strand serving count exists yet,
251
+ * so {@link strandClusterPolicy} declares nothing and the 2 here governs the repair floor
252
+ * exactly as the paragraph above describes, alongside the membership admission gate's
253
+ * low-confidence fallback. Do not raise it if a strand yardstick ever does get declared
254
+ * (`backlog/feat-strand-yardstick-from-serving-machines`): a strand shared between phones cannot
255
+ * promise that `ceil(0.75 x N)` of its machines are awake, and the admission gate would refuse
256
+ * its writes. Same reason and same warning as {@link CONTROL_CLUSTER_POLICY}'s.
257
+ *
208
258
  * The `satisfies` is load-bearing for the same reason as {@link CONTROL_CLUSTER_POLICY}'s.
209
259
  */
210
260
  export declare const STRAND_CLUSTER_POLICY: Readonly<{
@@ -229,3 +279,121 @@ export declare const STRAND_CLUSTER_POLICY: Readonly<{
229
279
  * this comment described. See `docs/architecture.md` → "Replication cluster size".
230
280
  */
231
281
  export declare function resolveStrandClusterSize(configured?: number): number;
282
+ /**
283
+ * The repair yardstick to declare for a network, from the machines that SERVE that network and
284
+ * the breadth it replicates to.
285
+ *
286
+ * ## What this number does
287
+ *
288
+ * Optimystic's block repair (read-repair and reconcile) asks a block's cohort for the newest
289
+ * revision and trusts an answer only when enough independent peers agree on it. "Enough" is
290
+ * measured against a **declared** number rather than the peers currently visible — deliberately,
291
+ * because the visible set comes from unauthenticated routing, and a partition (or an attacker
292
+ * with routing influence) can shrink it. `clusterPolicy.repairCorroborationClusterSize` is that
293
+ * declaration; it moves the repair yardstick ALONE, leaving
294
+ * {@link CONTROL_CLUSTER_POLICY}'s / {@link STRAND_CLUSTER_POLICY}'s `assumedClusterSize` — the
295
+ * membership admission gate's yardstick — untouched. Optimystic resolves
296
+ * `repairCorroborationClusterSize -> assumedClusterSize -> clusterSize`, and a value that is not
297
+ * a positive integer falls THROUGH to the next term rather than being clamped, which is why the
298
+ * builders below refuse to pass a degenerate count at all.
299
+ *
300
+ * ## The arithmetic, and why the formula is what it is
301
+ *
302
+ * Three consumers read the resolved yardstick `N`. With `p` = cohort peers currently visible
303
+ * (self excluded) and Optimystic's `CORROBORATION_FLOOR` = 2:
304
+ *
305
+ * - **Repair corroboration floor** (`corroboratorCapacity` / `quorumSize` in
306
+ * `db-p2p/src/cluster/quorum-restore.ts`): capacity is `max(p, N - 1)` and the floor is
307
+ * `max(1, min(2, capacity))`. So `N = 1` and `N = 2` behave IDENTICALLY at every `p` — both
308
+ * leave the floor at a single voter whenever `p <= 1`. `N >= 3` pins the floor at two
309
+ * corroborators unconditionally, including when the view has shrunk to one peer. That is the
310
+ * entire point of declaring. `N` above 3 raises nothing further (the `min(2, ...)` caps it), so
311
+ * a larger declaration buys only honesty, never more repair.
312
+ * - **Commit freshness window** (`CoordinatorRepo.commitQuorumRulesOutRivals`): `fullCohortSize`
313
+ * is `max(observed cohort, N)`, and a local commit arms the lazy read-repair window only when
314
+ * `approvals > fullCohortSize / 2`. Here a larger `N` is NOT free — see the NOTE below.
315
+ * - The `repair-fault-tolerance` startup advisory — log text only.
316
+ *
317
+ * Hence `max(MIN_CLUSTER_SIZE, min(servingMachines, replicationBreadth))`, two clamps each for
318
+ * a stated reason:
319
+ *
320
+ * - **Capped at the replication breadth**, because a block only ever lives on
321
+ * `min(breadth, machines serving)` machines. A party of eight running strands at
322
+ * {@link DEFAULT_STRAND_CLUSTER_SIZE} has a cohort of four; declaring eight would make
323
+ * `approvals > 4` unsatisfiable (a super-majority of four is three), so the freshness window
324
+ * would never arm for any strand commit, forever — and buy nothing on repair, whose floor is
325
+ * already at its cap at four. Declare the machines that can hold the block, not the machines
326
+ * that exist.
327
+ * - **Floored at {@link MIN_CLUSTER_SIZE}**, so this can only ever RAISE the declared number
328
+ * relative to the un-derived constants. A node that authorizes nobody is a genuine
329
+ * founder-alone party, but it is equally a freshly seeded node whose membership rows have not
330
+ * replicated yet, or one whose trusted-owner anchor is empty — and this node cannot tell those
331
+ * apart. Declaring 1 is safety-neutral for repair (identical to 2 at every `p`, per the table
332
+ * above) but it WOULD let a solo commit arm the freshness window, which is wrong if the "solo"
333
+ * reading came from stale local records. Lifting that floor for a node that can prove it is
334
+ * genuinely alone is separate work: `backlog/feat-solo-node-arms-its-own-freshness-window`.
335
+ *
336
+ * The yardstick is per-node and protects only that node's own reads — no node refuses another
337
+ * anything over it — so two members disagreeing (one has replicated a new `CadrePeer` row, one
338
+ * has not) is harmless by construction, not a race to close.
339
+ *
340
+ * ## The contract: callers pass machines that SERVE this network, or nothing at all
341
+ *
342
+ * Over-declaring is not merely wasteful, it is unsafe in the availability direction: at
343
+ * `N >= 3` the corroboration floor is pinned at two peers, so a cohort that can only ever
344
+ * field one peer can never repair at all (`cluster-fetch:no-quorum`), where a declaration of
345
+ * 2 would have let its single peer answer. So the number a caller passes must be the machines
346
+ * that actually serve the network in question — never a broader population that merely bounds
347
+ * it from above, and never a guess. A caller with no trustworthy count passes nothing, which
348
+ * the builders below turn into "declare no yardstick at all".
349
+ *
350
+ * The CONTROL network satisfies this from the party's enrolled machines, because every enrolled
351
+ * machine runs the control node by construction: there, enrolled IS serving
352
+ * ({@link controlClusterPolicy}, fed by cadre-core's `enrolled-machine-store.ts`). A STRAND does
353
+ * not — it launches only on machines whose embedder registered its sApp config
354
+ * (`CadreNode.addStrand`), so a closed strand shared by two machines of a three-machine party is
355
+ * served by two, and declaring three would be exactly the unsafe over-declaration above. No
356
+ * authenticated per-strand serving count exists yet, so strand nodes declare NOTHING and run the
357
+ * frozen {@link STRAND_CLUSTER_POLICY} — keeping the known, upstream-tracked single-voter
358
+ * exposure (`backlog/debt-read-repair-single-voter-corroboration`) rather than risking the
359
+ * cannot-repair-at-all failure. Building that count:
360
+ * `backlog/feat-strand-yardstick-from-serving-machines`.
361
+ *
362
+ * Both arguments must be positive integers; the builders below sanitize before calling, and
363
+ * a degenerate value here propagates (`NaN` in, `NaN` out) rather than being clamped.
364
+ *
365
+ * NOTE: on a party of three or more machines where fewer than half are awake, a commit reaches a
366
+ * downsized cohort and no longer arms the freshness window, so each such block's next read costs
367
+ * one cohort consult per read-repair window instead of trusting the local commit. That is the
368
+ * correct reading of the honest number — a commit that reached two of five machines genuinely
369
+ * does not rule out a rival quorum, and arming it today is a consequence of under-declaring — and
370
+ * the cost is bounded at one consult per block per window by the read path's solo-self-skip exit.
371
+ * NOT measured under load. If a large, mostly-hibernating party's read path ever shows up as
372
+ * slow, this is the first thing to look at.
373
+ */
374
+ export declare function resolveRepairYardstick(servingMachines: number, replicationBreadth: number): number;
375
+ /**
376
+ * {@link CONTROL_CLUSTER_POLICY} with the repair yardstick declared from `enrolledMachines`
377
+ * (see {@link resolveRepairYardstick}); the frozen base object ITSELF when the count is unknown,
378
+ * so the cold path is provably today's behaviour and identity assertions keep holding.
379
+ *
380
+ * The parameter keeps the name `enrolledMachines`, unlike {@link strandClusterPolicy}'s, because
381
+ * for the CONTROL network the two quantities are one and the same by construction: every
382
+ * enrolled machine runs the control node, so the machines enrolled in the party ARE the machines
383
+ * serving this network.
384
+ */
385
+ export declare function controlClusterPolicy(enrolledMachines?: number): NonNullable<NodeOptions['clusterPolicy']>;
386
+ /**
387
+ * {@link STRAND_CLUSTER_POLICY} with the repair yardstick declared from `servingMachines` — the
388
+ * machines that serve THIS strand — and this strand's own `clusterSize` (already resolved by
389
+ * {@link resolveStrandClusterSize}, so it is at least {@link MIN_CLUSTER_SIZE} and the formula's
390
+ * two clamps can never cross); the frozen base object ITSELF when the count is unknown.
391
+ *
392
+ * **The unknown path is the production path today.** No authenticated per-strand serving count
393
+ * exists, and the party's enrolled-machine count is emphatically not one (see
394
+ * {@link resolveRepairYardstick}'s contract section), so cadre-core passes nothing and every
395
+ * strand node runs the frozen constant. The parameter and its threading through
396
+ * `StartStrandConfig.servingMachines` stay in place as the seam that
397
+ * `backlog/feat-strand-yardstick-from-serving-machines` plugs a real count into.
398
+ */
399
+ export declare function strandClusterPolicy(clusterSize: number, servingMachines?: number): NonNullable<NodeOptions['clusterPolicy']>;