Status: implemented (feat/composable-scenarios, genmodel/spec.rs + sim scenario). Ship-order steps 1-3
landed; step 4 (promote curated combos to named+goldened) and step 5 (NL front-end) are follow-ups.
- Offline:
lower_reproduces_every_scenarioprovesrender(spec) == args_file_inbyte-for-byte for all 13 named scenarios; round-trip + composite-order + total-render property tests green; full suite + clippy-D warningsclean. - Real-schema: a novel compose (
get_header=stream+clients=nethermind-prysm+timing_games) passessim preflight(helix config-parse against the real image). - End-to-end: a novel compose (
cb-timing-gamesbase +extra_validation, two-relays — no named scenario, no golden) rendered bysim scenario, stood up a full Kurtosis devnet, and BOTH composed features were positively proven from CB debug logs:feature.timing_gamesPASS (385 marker lines),feature.extra_validationPASS (126 marker lines); overall exit 0. The two WARNs (get_header deadline timeouts, p95 latency) are the expected artifacts of aggressive timing games, not failures.
A test scenario is one hardcoded Scenario enum variant mapped to one frozen golden YAML fixture (byte-diff
acceptance). Features (WS header-stream, timing-games, extra-validation, skip-sigverify, min-bid, mux /
multi-relay topology, EL/CL client pair, signer) are not composable: producing "WS + prysm + timing-games"
needs a whole new fixture and a new enum variant. The ask: make scenarios composable, drivable by a structured
(AI-targetable) surface, and enumerable/testable — without a combinatorial pile of golden fixtures.
ScenarioSpec — a flat struct of closed enums / Options is the single source of truth. It is the surface
a caller (human, or an agent emitting JSON) targets; it is lower()'s input; its armed_features() is the
verifier oracle. Closed enums make illegal values inexpressible; smart constructors make the three known
illegal combinations unrepresentable, so there is no validate() returning conflicts — illegal states don't
compile.
Axes (each maps onto an EXISTING seam, nothing new is emitted):
clients: ClientPair {GethLighthouse, NethermindPrysm}→ElCl::{DEFAULT, ALT}topology: Topology {Single, TwoRelays, DivergentRelays, Mux}— relay count + subsidy intent in one knobget_header: HeaderTransport {Http, Stream{api_key: Present|Absent}}— Absent = the ws-nokey negative controltiming_games: bool(timeouts 400/2000 ride it),extra_validation: bool,signer: boolsigverify: Sigverify {On, Skip, SkipPoisoned, PoisonedControl}— collapses the mutually-exclusive combosmin_bid: MinBid {None, Floor(f64)}— subsidy is DERIVED (Floor ⇒ subsidy 0), never a spec field
lower(spec, images, keys_dir) -> args_file — deterministic, total on any constructible spec. Reuses
verbatim: CbParams + its extra_pbs_lines/per_relay_lines Vec<String> seams, cb_toml, cb_toml_mux
(mux is a dedicated exclusive branch — structurally a different template), build_mev_params, ElCl,
poisoned_relay_url, WRONG_RELAY_PUBKEY, load_pubkeys. Subsidy, timeouts, and network_params are DERIVED
inside lower (they are non-orthogonal couplings each scenario bundles — see Honesty note). The seam-line
fragments are composed in a FIXED canonical order, pinned by a dedicated composite-spec test (below).
Acceptance model (byte-golden evolves, does not die):
- The 13 named scenarios:
NAMED: &[(&str, ScenarioSpec)]const table.every_scenario_matches_its_goldenbyte-diffslower(named)against the frozen golden (UNCHANGEDassert_matches_golden). This is the migration safety net AND the regression anchor.Scenario::from_name/ALLstay working, backed byNAMED. - The combinatorial space is NEVER byte-goldened. Two OFFLINE guards over an enumeration of the pruned product:
every_pruned_spec_renders_without_panic—loweris total across the legal space.- round-trip:
detect_enabled_features(lower(spec)) == spec.armed_features(). Documented as a RENDERER-DRIFT guard ONLY — it proves emit↔detect agree, NOT that the config is valid CB (both sides share the same key strings, so a shared typo passes;[pbs]has nodeny_unknown_fields). Real config validation issim preflight(Law 1), which callers run before a live run. - composite-spec fragment-order pin: a unit test asserting
to_cb_params()output for a COMPOSITE spec (e.g. timing+extra-validation) has the exact expected line order. The 13 goldens each pin ONE order; only a composite test catches a canonical-order regression on combinations they don't cover.
Driving surface (the "run me a scenario like X with Y and Z"):
sim generate --base <name> --set k=v,...— a deterministic keyword overlay: start from a named base, apply typed field updates, render. Zero model. This is the composability UX.sim generate --spec <file.json>— render a fullScenarioSpecsupplied as JSON (deny_unknown_fields). This is the AI-driven entry: an AI agent or chat UI composes the spec and passes it; validity is by construction because output comes fromlower().
The system is AI-driven by construction — the structured surface IS what an agent targets — without a
brittle in-binary LLM/NL parser. A natural-language front-end (sim scenario "<english>") is a thin, optional
add on top of this surface; it is deliberately deferred (see Cut list) until the deterministic core is proven.
- No
sim matrixlive-sweep verb / no "N/M pass" coverage integer. At ~10 min/cell live the sweep has no consumer, and a single pass-tally conflates config-rendered / never-run / expectation-downgraded cells — the coverage-theater trap. Enumeration ships as offline tests only. If genuine cross-situation coverage is wanted, the right form is promoting a few high-value combos to NAMED, runnable, byte-goldened scenarios (below). - No 4-valued
Expectation/expected_checks(spec). "Proven" is a RUNTIME outcome (a marker fires only if a bid/getHeader lands in the window; min-bid rejection needsrejections>0), so a spec cannot soundly declare it. Gating--require-feature-proofoff a per-spec expectation table re-hardens what the classifiers softened to WARN and manufactures flaky reds.--require-feature-proofkeeps deriving from the RENDERED config as it does today. The spec declares onlyarmed_features()(2-valued: armed / not). - No
Conflict/validate(). Smart constructors make illegal combos unrepresentable;min_bid ⇒ subsidy 0is a derivation, not a reportable clamp. - No in-binary NL/AI layer, no schemars overlay type yet. Deferred behind the deterministic surface.
The clients axis is the Law-7 matrix. CLs are the axis that matters for CB behavior (the blinded-block /
get_header flow), so the additional pairs vary the CL against geth; nethermind-prysm keeps its historical EL.
ClientPair variants: geth-lighthouse (default), nethermind-prysm, geth-teku, geth-nimbus,
geth-lodestar — i.e. all 5 mainstream CLs (lighthouse, prysm, teku, nimbus, lodestar). Adding a client is
an ElCl + ClientPair variant + serde name; the rpc_url naming (el-1-{el}-{cl}) is already parametric.
Rather than a Cartesian sweep, spec::curated() freezes a handful of genuinely-interesting composed specs as
named+goldened regression anchors (tests/fixtures/curated-configs/), each live-validated on a devnet
before its golden is trusted (a golden of a config that has never run is worthless). Emit them with
sim generate --curated.
| Curated point | Why | Live result |
|---|---|---|
cb-basic-teku |
teku CL (Law 7) | 14 PASS / 0 WARN / 0 FAIL; 33 payloads, 100% MEV |
cb-basic-nimbus |
nimbus CL (Law 7) | 14 / 0 / 0; 31 payloads, 93.9% MEV |
cb-basic-lodestar |
lodestar CL (Law 7) | 14 / 0 / 0; 31 payloads, 93.9% MEV |
cb-ws-prysm |
ws stream on prysm — the highest-suspicion route coupling | 15 / 1 / 0; ws stream FIRED (30 headers, 1 startup-race fallback) — the coupling concern is refuted by measurement |
cb-timing-extra-validation |
the composition claim (both markers must fire) | both feature.timing_games + feature.extra_validation proven from CB logs |
Not curated as ws×CL points: poison × prysm and min_bid × prysm were dropped on reassessment —
skip_sigverify and min_bid are CB-internal (the CL never participates; the commit_boost_config block is
byte-identical across CLs), so a client pairing adds no Law-7 coverage that cb-sigverify-diff / cb-min-bid
don't already have.
An initial ws×CL sweep appeared to show the stream failing on teku/nimbus/lodestar while working on
lighthouse/prysm — but that was not CL-dependent. Root cause: the devnet pulls the public helix image
ghcr.io/gattaca-com/helix-relay:main, and in helix main the header-stream admission is stubbed — the
ApiProvider::admit_header_stream trait method has a default that unconditionally returns
Err("header stream not available"), and the open-source DefaultApiProvider does not override it (its
get_preferences even hard-codes api_key: None). The real admission logic lives in gattaca's private
ApiProvider, not shipped in the public build. So :main refuses the stream for every proposer, any CL.
The lighthouse/prysm runs "worked" only because they ran against an older :main: the image is a mutable
tag, and it was rebuilt with the stub between those runs and the teku/nimbus/lodestar ones (confirmed by the
image's build timestamp straddling the success/failure boundary, the running digest, and that main has exactly
one admit_header_stream — the stub, no override). The vendored ./helix submodule (develop) still carries
the working public admission (header_stream.rs calls check_api_key, and get_preferences reads the
x-api-key header), which is why helix is vendored.
Resolution: Images::default() runs the published helix-relay DEVELOP image, pinned by digest, for
every scenario. Nothing needs a local helix build, and just build-helix-image is now only the escape
hatch for an unpublished branch or a local patch.
just e2e configs/generated/cb-ws-stream.yml # or any composed ws scenarioBoth halves of that default are load-bearing. develop, because current helix main carries no
header_stream route at all, so it cannot serve the stream in any configuration; there is no relay
config that makes :main a ws scenario. By DIGEST, because this section is the record of the
mutable-tag skew trap: the same tag silently changed the feature under test between two runs, and the
lesson is not "use a different tag" but "a result is only reproducible against an immutable
reference". Bumping the digest is a deliberate act, and the ws scenarios get re-run when it happens.
Confirmed (2026-08-13): sim scenario --set clients=geth-teku,get_header=stream — the exact CL that
"failed" against :main — streams cleanly against the submodule build: feature.ws_header_stream PASS
(37 CB proof lines), feature.ws_stream_fallback PASS (zero HTTP fallbacks), 33 payloads delivered, 100% MEV.
Proof it was never CL-dependent.
Submodule-helix data-api caveat (now hardened): the relay.validator_registrations check queries the
relay's /relay/v1/data/validator_registration data-api, which the develop build answers from an unpopulated
postgres backing (the in-memory cache that the admission path uses is populated — the ws stream authenticated).
So it reports 0/128 even though registrations worked. The check is now hardened: when it sees 0/N and the
relay confirmed delivery (relay.payloads_delivered_multi PASS — a relay only delivers to registered
proposers), it SKIPs with an explanation instead of FAILing. A genuine total-registration failure (0 registered,
0 delivered) still FAILs, and is independently caught by the tier-1 delivery check.
The struct advertises a product space, but lower only honors a subregion: mux composes with nothing (dedicated
template), min-bid/poison require Single relay, subsidy/timeouts/network_params are derived not chosen. This is
real and stated: the win is that the illegal region is made unrepresentable by construction (smart constructors)
rather than absent-from-a-match, and that the 13 named scenarios are reproduced byte-for-byte through the new
path. We do NOT market "features combine freely."
ScenarioSpec+ smart constructors +lower+armed_features+ the 13-named migration (byte-golden net).- Offline guards: round-trip +
renders_without_panic+ composite fragment-order pin. sim generate --base/--setand--specdriving surface.- (Follow-up) promote the 4 curated combos to named+goldened scenarios after a live run each.
- (Deferred) NL front-end, if demand.