Affected versions: confirmed in @serfab/cadre-core@0.9.0 (and unchanged from 0.8.1 — both
hardcode the same literal at both call sites), against the resolved runtime peer
@optimystic/db-p2p@0.17.0, whose new membership admission gate consumes exactly the two
options cadre-core fixes and omits.
Companion issues
All four surfaced from the same 4-node cold-start runs (2 Android emulators + 2 Node drones,
2026-07-31). They are independent defects — none is a duplicate of another — but they interact, so
the list is here in full rather than referred to in passing:
| # |
Where |
Status |
Relationship to this issue |
| 1 |
this issue — packages/cadre-core, clusterSize: 3 hardcoded, clusterPolicy not injectable |
filing now |
The missing seam. |
| 2 |
gotchoices/Optimystic#10 — the db-p2p membership admission gate has no genesis exemption |
filed |
Compounds with this issue. That gate is what makes the missing passthrough load-bearing: at cluster genesis it refuses the founding transaction, and the one supported way to opt out (allowUnvalidatedSmallCluster) is the option this issue cannot reach. Still independent in both directions — see "How this relates to the db-p2p gate" below. |
| 3 |
#1 — a CadreNode's control node and every strand node share one peerId, colliding at a shared circuit-relay-v2 relay |
filed, recommendation revised 2026-07-31 |
Same package, same seam shape (a per-node-type knob a consumer cannot reach), and its revised fix direction is directly relevant to the design question at the end of this issue. Fix landed locally as a yarn patch; fixing it is what let the runs progress far enough to hit the cluster gate. |
| 4 |
gotchoices/Optimystic#9 — @chainsafe/libp2p-gossipsub@14 paired with libp2p@3, every outbound stream throws |
filed |
Unrelated mechanism, same runs. Listed for completeness: its unbounded error stream dominated the drone logs while the cluster blockers were being diagnosed. |
Summary
CadreNode builds the createLibp2pNode options for both its node types with a hardcoded
cluster policy:
clusterSize: 3,
clusterPolicy: { allowDownsize: true, sizeTolerance: 0.5 },
Two problems, both of which became load-bearing when @optimystic/db-p2p@0.17.0 introduced its
membership admission gate:
clusterSize: 3 is a literal with no CadreNodeConfig override. NetworkConfig exposes no
cluster-size field, so a consumer running a deliberately small cohort (or a large one) cannot
change it.
clusterPolicy is constructed inline and omits allowUnvalidatedSmallCluster. db-p2p
defaults that option to false (fail closed). Because cadre-core passes a clusterPolicy object
that does not include the key, and offers no way to inject one, the escape hatch db-p2p provides
for precisely this situation cannot be reached through cadre-core at all.
The consequence is that a cadre cold-starting a cohort deadlocks: db-p2p's gate refuses the founding
transaction as a possible self-shrink (membership-not-admitted:low-confidence-downsize), and the
one supported way to opt out of that refusal is not plumbed.
Reproduction
Both call sites, in the installed 0.9.0 dist:
// dist/cadre-node.js:503-504 — CONTROL node
clusterSize: 3,
clusterPolicy: { allowDownsize: true, sizeTolerance: 0.5 },
// dist/strand-instance-manager.js:170-173 — every STRAND node
clusterSize: 3,
clusterPolicy: {
allowDownsize: true,
sizeTolerance: 0.5
},
Neither reads anything from CadreNodeConfig. Confirming the absence of an override:
$ grep -n "clusterSize\|clusterPolicy" node_modules/@serfab/cadre-core/dist/types.d.ts
$ # (no output — NetworkConfig / CadreNodeConfig expose neither)
Downstream, db-p2p resolves the missing key to the fail-closed default
(db-p2p/dist/src/libp2p-node-base.js:418):
allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
…and its member-side gate then refuses every below-3-peer declared set while FRET confidence is
still 0 (db-p2p/dist/src/cluster/cluster-repo.js:691-708).
Observed end to end on a 4-node topology (2 Android emulators + 2 Node drones, cold-started
together), on the first schema DDL:
Transaction rejected by validators (2/2 rejected):
12D3KooWR3zSSnvr…: membership-not-admitted:low-confidence-downsize;
12D3KooWMVS8M6dQ…: membership-not-admitted:low-confidence-downsize
Deterministic across two independent runs with freshly wiped state.
The runnable test at the bottom of this issue isolates the seam without any of that: it drives the
real CadreNode.createControlNode() against a stubbed createLibp2pNode and shows a consumer's
clusterSize: 2 and clusterPolicy: { allowUnvalidatedSmallCluster: true } being dropped —
db-p2p receives clusterSize: 3 and a policy object with no such key.
Expected: a consumer can tell cadre-core the shape of its cohort — at minimum, that it knowingly
runs below db-p2p's safe floor — the same way db-p2p lets its own direct callers do.
Actual: cluster sizing is fixed at 3, allowUnvalidatedSmallCluster is unreachable, and a
small-cohort deployment cannot start.
Root cause
this.config is stored verbatim (cadre-node.js:171), but the cluster options are not read from it
— they are literals written into the option object at each createLibp2pNode call site
(cadre-node.js:503-504, strand-instance-manager.js:170-173). Contrast the adjacent lines, which
do forward consumer config:
...(config.privateKey && { privateKey: config.privateKey }),
...(config.network?.transports && { transports: config.network.transports }),
...(config.network?.listenAddrs && { listenAddrs: config.network.listenAddrs }),
...(config.network?.connectionGater && { connectionGater: config.network.connectionGater })
The cluster options simply were not given the same treatment.
How this relates to the db-p2p gate (companion issue 2)
They are independent defects that compound, and both are worth fixing:
Fixing Optimystic#10 alone unblocks the deployment. Fixing this issue alone would also unblock it (by
letting the consumer set allowUnvalidatedSmallCluster: true), but at the cost of turning off a real
safety gate — so Optimystic#10 is the one to push on, and this is the durable configurability fix.
Fix
Additive and symmetric with the existing forwarding style — add optional fields to NetworkConfig
(or CadreNodeConfig) and prefer them when present, so callers that set nothing see zero behavior
change:
// cadre-node.ts (createControlNode) and strand-instance-manager.ts (buildStrandRuntime)
- clusterSize: 3,
- clusterPolicy: { allowDownsize: true, sizeTolerance: 0.5 },
+ clusterSize: config.network?.clusterSize ?? 3,
+ clusterPolicy: {
+ allowDownsize: true,
+ sizeTolerance: 0.5,
+ ...config.network?.clusterPolicy,
+ },
// types.ts — NetworkConfig
+ /** Full cluster size for consensus sizing. Defaults to 3. */
+ clusterSize?: number;
+ /** Cluster policy overrides forwarded to the underlying db-p2p node
+ * (e.g. `allowUnvalidatedSmallCluster` for knowingly-small cohorts). */
+ clusterPolicy?: {
+ allowDownsize?: boolean;
+ sizeTolerance?: number;
+ allowUnvalidatedSmallCluster?: boolean;
+ superMajorityThreshold?: number;
+ };
Spreading the consumer's clusterPolicy last preserves cadre-core's defaults for keys the consumer
does not set, while letting allowUnvalidatedSmallCluster (and any future db-p2p policy key) through.
One design question: shared or per-node-type?
Whether the control node and strand nodes should share one setting or take independent ones is worth
a moment's thought, and sereus#1 (companion issue 3) is the relevant precedent — in its revised
form especially. That issue's recommendation was updated on 2026-07-31: its primary fix is now
distinct transport peerIds per node (decoupling the transport peerId from the cadre's owner
key), with the per-strand strandNetwork override retained as the companion approach for callers
that deliberately reuse one privateKey/peerId. Both halves of that revision point the same way as
this issue: CadreNodeConfig is growing knobs that a cadre's control node and its strand nodes need
to set differently.
Forwarding a single value from network is still the minimal step here and can be split later; if
sereus#1's per-node-type config direction lands first, these two options are natural candidates to
travel with it.
Severity
High. In combination with db-p2p 0.17's admission gate it is a hard blocker for cold-starting any
cohort smaller than 3 confident peers — the deployment cannot create a schema, so it never becomes
usable. Even once the db-p2p genesis defect is fixed, the missing passthrough remains a real
limitation: consumers cannot size their cohort or opt into small-cluster operation.
Regression test
Self-contained; no test-framework dependency (built-in node:test + node:assert) and no sockets —
createLibp2pNode is mocked so it records the options cadre-core hands it, and the test then drives
the real CadreNode.createControlNode(), the same builder start() calls. Drop it in as
packages/cadre-core/test/cadre-cluster-config-passthrough.test.mjs and run:
node --test --experimental-test-module-mocks \
packages/cadre-core/test/cadre-cluster-config-passthrough.test.mjs
Against 0.9.0 both assertions fail — the consumer's values never reach db-p2p:
✖ a consumer-supplied clusterSize reaches the underlying db-p2p node
✖ a consumer-supplied clusterPolicy reaches the underlying db-p2p node
ℹ tests 2
ℹ pass 0
ℹ fail 2
✖ failing tests:
✖ a consumer-supplied clusterSize reaches the underlying db-p2p node
AssertionError [ERR_ASSERTION]: the consumer asked for a 2-peer cohort but db-p2p received
clusterSize: 3. It is a literal at cadre-node.ts (createControlNode) and strand-instance-manager.ts
(buildStrandRuntime), and NetworkConfig exposes no field for it, so cohort size cannot be
configured at all.
actual: 3, expected: 2
✖ a consumer-supplied clusterPolicy reaches the underlying db-p2p node
AssertionError [ERR_ASSERTION]: the consumer opted into small-cluster operation but db-p2p received
clusterPolicy {"allowDownsize":true,"sizeTolerance":0.5} — the key is absent, so db-p2p resolves it
to its fail-closed default (`options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false`).
cadre-core builds clusterPolicy inline and offers no way to inject one, so db-p2p's own escape
hatch for knowingly-small cohorts is unreachable through cadre-core.
actual: undefined, expected: true
Both pass once the two options are forwarded from config. The third assertion (that cadre-core's own
allowDownsize default survives a partial consumer override) guards the spread order in the fix
above. The mock re-exports the real db-p2p namespace and overrides only createLibp2pNode, so other
consumers in the graph keep resolving normally.
packages/cadre-core/test/cadre-cluster-config-passthrough.test.mjs
/**
* cadre-cluster-config-passthrough.test.mjs
*
* Regression test for: `CadreNode` writes `clusterSize: 3` and an inline `clusterPolicy` as literals
* into the `createLibp2pNode` options at BOTH call sites, and `CadreNodeConfig`/`NetworkConfig`
* expose no field for either — so a consumer can neither size its cohort nor reach db-p2p's
* `allowUnvalidatedSmallCluster` escape hatch, which db-p2p defaults to `false` (fail closed).
*
* The test mocks `@optimystic/db-p2p` so `createLibp2pNode` records the options object cadre-core
* hands it, then drives the REAL `CadreNode.createControlNode()` — cadre-core's own control-node
* builder, the one `start()` calls. No sockets: the factory is a stub.
*
* The consumer below asks for a 2-peer cohort and knowingly opts into small-cluster operation. Both
* assertions check that what the consumer asked for is what db-p2p receives.
*
* These assertions are written to FAIL while the defect is present and PASS once the two options are
* forwarded from config.
*
* Run: node --test --experimental-test-module-mocks cadre-cluster-config-passthrough.test.mjs
* (No test-framework dependency — uses the built-in `node:test` and `node:assert`.)
*/
import test, { mock } from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPair } from '@libp2p/crypto/keys';
/** Options handed to createLibp2pNode by whichever cadre-core call site ran last. */
let capturedOptions;
// The mock replaces the module for EVERY importer in the graph, so re-export the real namespace and
// override only `createLibp2pNode` — other db-p2p consumers (e.g. quereus-plugin-optimystic) need
// their own imports to keep resolving.
const realDbP2p = await import('@optimystic/db-p2p');
// Must be registered BEFORE cadre-core is imported, so its `createLibp2pNode` specifier resolves here.
mock.module('@optimystic/db-p2p', {
exports: {
...realDbP2p,
createLibp2pNode: async (options) => {
capturedOptions = options;
// Minimal stand-in for a libp2p node — cadre-core only needs it to look like one here.
return {
peerId: { toString: () => 'stub-peer' },
services: {},
addEventListener () {},
getMultiaddrs: () => [],
start: async () => {},
stop: async () => {},
};
},
},
});
const { CadreNode } = await import('@serfab/cadre-core');
/** A consumer that deliberately runs a 2-peer cohort and says so. */
const CONSUMER_CLUSTER_SIZE = 2;
async function buildControlNodeOptions () {
capturedOptions = undefined;
const node = new CadreNode({
controlNetwork: { partyId: 'partyX', bootstrapNodes: [] },
privateKey: await generateKeyPair('Ed25519'),
profile: 'edge',
network: {
// What a consumer would set if the seam existed. Today these are simply ignored.
clusterSize: CONSUMER_CLUSTER_SIZE,
clusterPolicy: { allowUnvalidatedSmallCluster: true },
},
});
await node.resolveIdentityKey();
await node.createControlNode();
assert.ok(capturedOptions !== undefined, 'createLibp2pNode was not called — test harness problem');
return capturedOptions;
}
test('a consumer-supplied clusterSize reaches the underlying db-p2p node', async () => {
const options = await buildControlNodeOptions();
assert.equal(
options.clusterSize,
CONSUMER_CLUSTER_SIZE,
`the consumer asked for a ${CONSUMER_CLUSTER_SIZE}-peer cohort but db-p2p received ` +
`clusterSize: ${options.clusterSize}. It is a literal at cadre-node.ts (createControlNode) ` +
'and strand-instance-manager.ts (buildStrandRuntime), and NetworkConfig exposes no field for ' +
'it, so cohort size cannot be configured at all.',
);
});
test('a consumer-supplied clusterPolicy reaches the underlying db-p2p node', async () => {
const options = await buildControlNodeOptions();
assert.equal(
options.clusterPolicy?.allowUnvalidatedSmallCluster,
true,
'the consumer opted into small-cluster operation but db-p2p received clusterPolicy ' +
`${JSON.stringify(options.clusterPolicy)} — the key is absent, so db-p2p resolves it to its ` +
'fail-closed default (`options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false`). ' +
'cadre-core builds clusterPolicy inline and offers no way to inject one, so db-p2p\'s own ' +
'escape hatch for knowingly-small cohorts is unreachable through cadre-core.',
);
// The cadre-core defaults must survive a partial consumer override — spread the consumer's
// policy over them rather than replacing the object.
assert.equal(
options.clusterPolicy?.allowDownsize,
true,
'cadre-core\'s own clusterPolicy defaults should be preserved for keys the consumer does not set',
);
});
Affected versions: confirmed in @serfab/cadre-core@0.9.0 (and unchanged from 0.8.1 — both
hardcode the same literal at both call sites), against the resolved runtime peer
@optimystic/db-p2p@0.17.0, whose new membership admission gate consumes exactly the twooptions cadre-core fixes and omits.
Companion issues
All four surfaced from the same 4-node cold-start runs (2 Android emulators + 2 Node drones,
2026-07-31). They are independent defects — none is a duplicate of another — but they interact, so
the list is here in full rather than referred to in passing:
packages/cadre-core,clusterSize: 3hardcoded,clusterPolicynot injectableallowUnvalidatedSmallCluster) is the option this issue cannot reach. Still independent in both directions — see "How this relates to the db-p2p gate" below.CadreNode's control node and every strand node share one peerId, colliding at a shared circuit-relay-v2 relay@chainsafe/libp2p-gossipsub@14paired withlibp2p@3, every outbound stream throwsSummary
CadreNodebuilds thecreateLibp2pNodeoptions for both its node types with a hardcodedcluster policy:
Two problems, both of which became load-bearing when
@optimystic/db-p2p@0.17.0introduced itsmembership admission gate:
clusterSize: 3is a literal with noCadreNodeConfigoverride.NetworkConfigexposes nocluster-size field, so a consumer running a deliberately small cohort (or a large one) cannot
change it.
clusterPolicyis constructed inline and omitsallowUnvalidatedSmallCluster. db-p2pdefaults that option to
false(fail closed). Because cadre-core passes aclusterPolicyobjectthat does not include the key, and offers no way to inject one, the escape hatch db-p2p provides
for precisely this situation cannot be reached through cadre-core at all.
The consequence is that a cadre cold-starting a cohort deadlocks: db-p2p's gate refuses the founding
transaction as a possible self-shrink (
membership-not-admitted:low-confidence-downsize), and theone supported way to opt out of that refusal is not plumbed.
Reproduction
Both call sites, in the installed 0.9.0 dist:
Neither reads anything from
CadreNodeConfig. Confirming the absence of an override:Downstream, db-p2p resolves the missing key to the fail-closed default
(
db-p2p/dist/src/libp2p-node-base.js:418):…and its member-side gate then refuses every below-3-peer declared set while FRET confidence is
still 0 (
db-p2p/dist/src/cluster/cluster-repo.js:691-708).Observed end to end on a 4-node topology (2 Android emulators + 2 Node drones, cold-started
together), on the first schema DDL:
Deterministic across two independent runs with freshly wiped state.
The runnable test at the bottom of this issue isolates the seam without any of that: it drives the
real
CadreNode.createControlNode()against a stubbedcreateLibp2pNodeand shows a consumer'sclusterSize: 2andclusterPolicy: { allowUnvalidatedSmallCluster: true }being dropped —db-p2p receives
clusterSize: 3and a policy object with no such key.Expected: a consumer can tell cadre-core the shape of its cohort — at minimum, that it knowingly
runs below db-p2p's safe floor — the same way db-p2p lets its own direct callers do.
Actual: cluster sizing is fixed at 3,
allowUnvalidatedSmallClusteris unreachable, and asmall-cohort deployment cannot start.
Root cause
this.configis stored verbatim (cadre-node.js:171), but the cluster options are not read from it— they are literals written into the option object at each
createLibp2pNodecall site(
cadre-node.js:503-504,strand-instance-manager.js:170-173). Contrast the adjacent lines, whichdo forward consumer config:
The cluster options simply were not given the same treatment.
How this relates to the db-p2p gate (companion issue 2)
They are independent defects that compound, and both are worth fixing:
allowUnvalidatedSmallCluster, db-p2p would still deadlock at genesisfor every consumer that does not set it — including anyone using db-p2p directly with a
clusterSizeand default policy. That is The membership admission gate has no genesis exemption, so a cold-starting cluster's founding transaction can never be admitted Optimystic#10.clusterSize: 3with no way for aconsumer to size their cohort or reach any db-p2p cluster-policy knob. That is this issue.
Fixing Optimystic#10 alone unblocks the deployment. Fixing this issue alone would also unblock it (by
letting the consumer set
allowUnvalidatedSmallCluster: true), but at the cost of turning off a realsafety gate — so Optimystic#10 is the one to push on, and this is the durable configurability fix.
Fix
Additive and symmetric with the existing forwarding style — add optional fields to
NetworkConfig(or
CadreNodeConfig) and prefer them when present, so callers that set nothing see zero behaviorchange:
Spreading the consumer's
clusterPolicylast preserves cadre-core's defaults for keys the consumerdoes not set, while letting
allowUnvalidatedSmallCluster(and any future db-p2p policy key) through.One design question: shared or per-node-type?
Whether the control node and strand nodes should share one setting or take independent ones is worth
a moment's thought, and sereus#1 (companion issue 3) is the relevant precedent — in its revised
form especially. That issue's recommendation was updated on 2026-07-31: its primary fix is now
distinct transport peerIds per node (decoupling the transport peerId from the cadre's owner
key), with the per-strand
strandNetworkoverride retained as the companion approach for callersthat deliberately reuse one
privateKey/peerId. Both halves of that revision point the same way asthis issue:
CadreNodeConfigis growing knobs that a cadre's control node and its strand nodes needto set differently.
Forwarding a single value from
networkis still the minimal step here and can be split later; ifsereus#1's per-node-type config direction lands first, these two options are natural candidates to
travel with it.
Severity
High. In combination with db-p2p 0.17's admission gate it is a hard blocker for cold-starting any
cohort smaller than 3 confident peers — the deployment cannot create a schema, so it never becomes
usable. Even once the db-p2p genesis defect is fixed, the missing passthrough remains a real
limitation: consumers cannot size their cohort or opt into small-cluster operation.
Regression test
Self-contained; no test-framework dependency (built-in
node:test+node:assert) and no sockets —createLibp2pNodeis mocked so it records the options cadre-core hands it, and the test then drivesthe real
CadreNode.createControlNode(), the same builderstart()calls. Drop it in aspackages/cadre-core/test/cadre-cluster-config-passthrough.test.mjsand run:Against 0.9.0 both assertions fail — the consumer's values never reach db-p2p:
Both pass once the two options are forwarded from config. The third assertion (that cadre-core's own
allowDownsizedefault survives a partial consumer override) guards the spread order in the fixabove. The mock re-exports the real db-p2p namespace and overrides only
createLibp2pNode, so otherconsumers in the graph keep resolving normally.
packages/cadre-core/test/cadre-cluster-config-passthrough.test.mjs