Skip to content

CadreNode hardcodes clusterSize: 3 and drops allowUnvalidatedSmallCluster, making db-p2p's own escape hatch unreachable #2

Description

@risavian

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:

  1. 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.
  2. 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',
	);
});

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions