test(config): characterize the legacy configuration surface with native fuzzing (PLT-775) - #3816
test(config): characterize the legacy configuration surface with native fuzzing (PLT-775)#3816bdchatham wants to merge 16 commits into
Conversation
…ve fuzzing (PLT-775)
Phase 1 of replacing seid's configuration management: lock the behavior of
the existing surface so SeiConfigManager can be shown backwards compatible
against an executable contract rather than a prose one. Tests only — no
production code changes.
Adds testutil/configtest, a harness the suites and the forthcoming
differential harness share: Isolate pins the process environment and $HOME
(the legacy path reads bare env vars and lets $HOME outrank --home), Home
builds fixture node directories, Dump renders a resolved view as
diff-friendly text carrying each leaf's Go type, and KeySpec/CheckRow turn
the read-site manifest into checkable rows. testutil/fuzzing/config.go
decodes fuzzer scalars into the value shapes viper actually produces,
following the convention cosmos.go already set.
Covers, across 54 fuzz targets and 79 characterization tests:
- the seam itself: which subcommands get Apply (a prefix match on Use, so
any future init* silently skips it), and SEI_CONFIG_MANAGER selection at
its real call site in the root PersistentPreRunE
- every appOpts section reader, plus baseapp's four construction-time
reads and the second app.toml parser in server/config.GetConfig
- Apply's four resolution layers, both boot channels, all three viper
universes, malformed-TOML totality, materialization idempotence
- start's PreRunE pruning fail-fast and its chain-id mismatch panic
- the config-to-consensus wiring: UPGRADE_VERSION_LIST replacing the
upgrade-handler list, giga_executor.enabled driving
SkipLastResultsHashValidation, hash-vault-disabled-unsafe's root scope
- the process-global viper's empty env prefix and the $HOME-outranks---home
trap; the client viper's missing key replacer
- privval load-or-generate, the genesis reader's time completion,
upgrade-info.json, the DB and consensus-WAL layout fallbacks, tx-index
sink selection, and sei-db's tilde expansion
Deliberately records legacy defects as pinned behavior, since changing them
is a migration rather than a fix: the [state-store] zero-clobber, inert
template keys, and the panic-on-non-string genesis.import-file read.
Behaviors the fuzzer surfaced that were previously undocumented:
- SEID_* for a template-rendered, flag-less key is inert on a node's first
start and effective on every restart, because the branch that creates
config.toml never reads it back while the branch that finds one does
- a Uint64 cobra flag's app.toml integer reaches appOpts.Get as a string;
viper's pflag switch converts int*/bool and passes uint64 through as text
- unsafe-skip-upgrades in app.toml fails the boot: bindFlags renders the
slice through %v and pflag cannot parse the result
- tx-index.indexer holding "null" plus an unrecognized name resolves
nondeterministically, because sink selection ranges over a map; with
"null" plus kv the result is stable but a tx_index store is opened and
orphaned in about one run in eight
- Apply never validates a pre-existing config.toml, so mode = "" boots
clean and fails later from node.New
- under the empty env prefix, InitEnv's underscore-prefixed duplicates are
themselves resolvable viper keys, so the global viper's key space is the
whole environment twice over
Out of scope, and named rather than silently absent: client and operator
tooling with its own config universe, cosmovisor (separate module), benchmark
and simulation build-tag paths, DB-identity files, and the reads inside
unexported startInProcess and the node-lifecycle path, which need a running
node and belong to an integration harness.
Reviewed via /xreview (shared-stack, T3): systems-engineer as assigned
dissenter, security-specialist, sei-network-specialist, idiomatic-reviewer.
Ledger: bdchatham-designs designs/config-manager/xreview/
phase1-legacy-config-fuzz-characterization.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR SummaryLow Risk Overview New fuzz and unit tests cover section readers ( Tests use shared Reviewed by Cursor Bugbot for commit 5350253. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3816 +/- ##
==========================================
- Coverage 60.24% 59.44% -0.80%
==========================================
Files 2332 2246 -86
Lines 195178 185061 -10117
==========================================
- Hits 117578 110004 -7574
+ Misses 66954 65250 -1704
+ Partials 10646 9807 -839
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
A large, well-documented tests-only PR (54 fuzz targets + 79 characterization tests) that turns the legacy seid config surface into an executable contract; the harness design (testutil/configtest) is sound and the manifest-as-data approach is the right shape for the upcoming differential. No production behavior is touched and I found no correctness bugs in the code under test, but there are several test-hygiene issues worth fixing: an unrestored os.Chdir, an unbounded channel wait around the real start RunE, a t.Skip-on-behavior-change pattern that defeats the suite's stated purpose, one unreachable assertion branch, and one dead manifest column.
Findings: 0 blocking | 14 non-blocking | 7 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's second-opinion review file (
cursor-review.md) is empty — that pass produced no output, so this review merges only Claude's and Codex's findings. - The
t.Skip-when-legacy-behavior-changes pattern appears at six sites (sei-tendermint/libs/cli/setup_config_fuzz_test.go:78and:178,sei-wasmd/x/wasm/wasm_config_fuzz_test.go:84,sei-cosmos/baseapp/config_fuzz_test.go:110,cmd/seid/cmd/startprerun_config_fuzz_test.go:179,sei-tendermint/types/genesis_config_fuzz_test.go:94). Codex flagged this as P1; I agree it weakens the contract but treat it as non-blocking since the intent is documented in each comment. Worth reconsidering as a group before Phase 2 depends on these rows. - Nothing in the diff wires the 54 fuzz targets into CI with
-fuzz, so ingo testthey only execute their seed corpora. The PR body says each target was fuzzed manually for 15–40s; consider a scheduled/nightly-fuzzjob (or committedtestdata/fuzzcorpora) so the coverage claimed here is reproducible rather than a one-time act. - Several assertions restate the implementation rather than an independent rule, so they cannot fail:
app/consensus_config_fuzz_test.go:66re-derivesstrings.FieldsFunc+semver.Sortexactly asoverrideList()does, andcmd/seid/cmd/startprerun_config_fuzz_test.go:108comparescmd.PreRunE's verdict against the veryGetPruningOptionsFromFlags(serverCtx.Viper)call PreRunE makes. Pinning the vocabulary as literals (e.g. the four accepted pruning names, an expected sorted output for a fixed input) would give these rows teeth. - Unused exported helpers in the new shared packages:
fuzzing.TOMLScalar,configtest.DumpViperKeys,Home.Read,AppOpts.With,AppOpts.Without. Fine if the forthcoming differential harness consumes them, but as of this diff they are dead API in a testutil package. configtest.Isolate's doc correctly notes it does not restoreconfig.SetConfigTemplateorseilog.SetDefaultLevel. SinceapplyThroughcallsinitAppConfig()and Apply sets the log level, a target that resolveslog_level=debugleaves the wholecmd/seid/cmdbinary at debug afterwards. Harmless today (nothing asserts on level) but it makes the package order-dependent the moment something does — a save/restore inIsolatewould close it now rather than later.app/consensus_config_fuzz_test.go:121registerst.Cleanup(app.Close)inside the loop, so both fully-constructed apps stay open until the test ends. Closing each app at the end of its iteration would halve peak resource use.- 7 suggestion(s)/nit(s) flagged inline on specific lines.
…orking directory Two Bugbot findings on #3816, both real. The guard assertions in the GetConfig suite compared a resolved leaf against DumpAt(path, 0), which renders as int(0). That never compares equal to a uint, uint32 or float64 leaf, so the absent-key clobber check and the DefaultIsZero honesty check were unsatisfiable for 8 of the 14 guarded rows, including grpc.max-open-connections and grpc-web.max-open-connections where a silent drop to 0 means unlimited. Both now compare an absent key against the same key resolved as an explicit 0. That needs no knowledge of the field's Go type, so it cannot go vacuous the same way. Verified by mutation: dropping the IsSet guard on grpc.max-open-connections fails both assertions, where the previous comparison passed. The tilde-expansion suite called os.Chdir into a t.TempDir with nothing restoring the working directory, so a later test in the package could run with a deleted CWD. Switched to t.Chdir, which restores on cleanup; confirmed with three -shuffle=on runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five fixes from the seidroid review of #3816. TestStartChainIDAgreementDoesNotPanic waited on an unbounded receive whose only reason for returning was the discarded-error nil-deref at sei-cosmos/server/start.go:178. Fixing that defect would have left the test hanging until the 10-minute panic with a node running while later tests cleared the environment underneath it. It now characterizes that nil-deref directly, which is bounded, deterministic, and covers a manifest row the PR description had listed as out of reach. Six t.Skipf sites reported a changed legacy behavior as a skip, which keeps CI green for exactly the drift this suite exists to make visible. They now fail with the reason and an instruction to record the change deliberately. The remaining skips are input-shape or environmental, and stay. The zero-genesis_time branch in FuzzGenesisDocFromJSONTimeCompletion was reachable only at year 1 Jan 1 UTC, which no seed supplied, so the target exercised only the preservation half. Seeded at -62135596800. ZeroIsInfinity was set on two grpcClamps rows and never read; the clamp applies uniformly. Dropped the field and corrected the comment that implied the distinction was row-specific. The post-Setenv assertion in TestPreRunReadsTheManagerGateOncePerInvocation re-checked a field already asserted non-nil, so it held whatever the gate did. It now compares the resolved viper's identity and rendered view across the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t paths Two more Bugbot findings, both the same shape: an assertion too weak to pin the behavior it documents. FuzzTracerAllowlists checked only that the resolved list was native, trimmed and de-duplicated, which the defaults already are, so a reader that ignored evm.trace_allowed_tracers and kept DefaultTraceAllowedTracers would have passed. It now compares the resolved list against the normalized input. FuzzBaseAppChainID checked only that ChainID was non-empty, so a build that stamped any constant would have passed. It now compares against cast.ToString of the input. Both verified by mutation: ignoring the tracer key, and stamping a constant chain-id, each fail the corresponding target now and passed before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ten list validLogLevels enumerated only the exact lower and upper spellings, but Apply parses with slog.Level.UnmarshalText, which is case-insensitive and also accepts an offset form. So "Info", "iNfO" and "INFO+2" are valid levels the target would have declared invalid, then asserted must fail the boot: a latent flake waiting on the fuzzer to generate one. The oracle is now slog itself, which is not the code under test here (Apply's error handling is), so deferring to it is not circular. Added seeds for the case-insensitive and offset forms the list missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
A large, unusually well-documented test-only PR (35 new files, 0 production changes) that turns the legacy config resolution surface into 54 fuzz targets plus characterization tests via a new testutil/configtest harness. One fuzz target asserts a property the code under test does not hold and will fail as soon as it is actually fuzzed; the rest are non-blocking hygiene and fragility notes.
Findings: 1 blocking | 11 non-blocking | 6 posted inline
Blockers
- None at the file/PR level.
- 1 blocking issue(s) flagged inline on specific lines.
Non-blocking
- The Cursor second-opinion pass produced no output (
cursor-review.mdis empty), so that perspective is missing from this review. - Nothing in CI runs
-fuzz, so all 54 targets only exercise theirf.Addseeds. That is exactly why the brokenFuzzReadUpgradeInfoFromDiskproperty survives review, and it means the suite's fuzzing value decays silently. Consider a nightly/scheduled-fuzzjob over the touched packages, or committing the interesting inputs astestdata/fuzzcorpus entries in addition to seeds. - Six exported harness helpers ship unused by any test in this PR:
configtest.DumpViperKeys,configtest.AppOpts.With,configtest.AppOpts.Without,configtest.Home.Read, andfuzzing.TOMLScalar. If they are for the forthcoming differential harness, that is fine, but consider landing them with their first consumer so reviewers can see the intended shape. - This is the first time the vendored/fork subtrees (
sei-cosmos,sei-tendermint,sei-db,sei-wasmd) import a parent-tree package (github.com/sei-protocol/sei-chain/testutil/configtest). It works today because they share onego.mod, but it adds a new coupling direction that will complicate any future upstream sync or module split. Worth a brief note intestutil/configtest's doc comment about that tradeoff (it currently only explains why it imports nothing). TestNullSinkCanOpenAnIndexStoreItThenDiscardsrecords a real production resource leak: withindexer = ["null","kv"],EventSinksFromConfigopens atx_indexstore in roughly one boot in eight and drops it with nothing holding it to close. Pinning it as behavior is consistent with the PR's charter, but it deserves a tracked follow-up rather than only a test comment.- Three tests (
TestNullMixedWithAnUnsupportedSinkIsNondeterministic,TestNullSinkAlwaysWinsOverARecognizedSink,TestNullSinkCanOpenAnIndexStoreItThenDiscards) assert on the statistics of Go's map-iteration randomization over 400-1000 runs. The flake probability is negligible today (~2^-1000 for the first), and the failure messages are excellent, so this is only a note: these assertions are about the runtime, not the config surface, and would break loudly if map iteration semantics ever changed. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
…re the TOML guard Three more findings, plus the process gap that let two of them through. FuzzReadUpgradeInfoFromDisk asserted that a decoded plan with a name must carry a non-zero height. ReadUpgradeInfoFromDisk only JSON-decodes and validates nothing, so the target pinned a rule the reader does not enforce, which is the one thing a characterization suite must not do. It now asserts json.Unmarshal's contract exactly, and the absent validation is recorded as its own row: a plan naming an upgrade with no height decodes cleanly to height 0. FuzzResolveAndCreateDirTildeExpansion did not filter absolute inputs and seeded /tmp/sei, so ResolveAndCreateDir MkdirAll'd outside the fixture on the real filesystem. Absolute inputs are now out of scope for that target, with the absolute case covered against a path under t.TempDir(). The TOML-writability guard existed in one target and not the two that needed it, so a generated chain-id holding invalid UTF-8 made the fixture document unparseable and the target reported it as a resolution finding. It is now configtest.IsTOMLWritable, used by all three. The process gap: the first two would have been caught by fuzzing those targets, and I had not fuzzed them. All 55 targets have now been swept under -fuzz and are clean. The PR description is corrected to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Test-only PR adding a shared config-characterization harness plus 54 fuzz targets and 79 tests; the harness design is strong and the manifest-as-data approach is sound. One fuzz target can create directories outside its temp fixture during a -fuzz sweep (relative .. paths bypass its IsAbs-only guard), and several targets swallow unexpected errors in ways that make them vacuous under regression.
Findings: 1 blocking | 11 non-blocking | 7 posted inline
Blockers
- None at the file/PR level.
- 1 blocking issue(s) flagged inline on specific lines.
Non-blocking
cursor-review.mdis empty — the Cursor second-opinion pass produced no output, so this review merges only Claude's and Codex's findings.- CI runtime is worth measuring before merge. The seed corpora across 54 targets include ~40 full
Applyboots with real file I/O, two fullapp.Newconstructions inTestGigaExecutorEnabledDrivesLastResultsHashValidation, and ~2,200EventSinksFromConfigiterations in the sink tests — all under-raceingo-test.yml. None is individually slow, but the aggregate lands across several shards. - Several tests are designed to fail when the defect they pin gets fixed (
TestNullMixedWithAnUnsupportedSinkIsNondeterministic,TestInitEnvDuplicatesTheEnvironmentUnderAnEmptyPrefix,TestQueryGasLimitTemplateLiteralDivergesFromTheInCodeDefault,TestApplyMaterializationOverridesOnlyApplyToACreatedConfigFile). That is deliberate and each failure message says so, which is the right design — but it means any future PR that fixes one of these legacy defects will see a red test that is not a regression. Worth calling out in the PR body or atestutil/configtest/AGENTS.mdso it isn't mistaken for one. testutil/configtestis a new shared harness that the forthcoming differential work will consume, and the repo convention (top-levelAGENTS.md) is a nested per-package guide for exactly this. The package doc comment inconfigtest.gocovers a lot of it; a shortAGENTS.mdrecording the invariants a new target must respect (callIsolatebefore touching env or$HOME, nevert.Parallel, guard fuzzer-supplied paths withfilepath.IsLocal, prefer failing overreturnon unexpected errors) would keep the next 100 targets consistent.FuzzUpgradeVersionListOverridecomputes its expectation withstrings.FieldsFunc(...)+semver.Sort(...), which is a line-for-line restatement ofparseUpgradesList. It can detect a change but not a bug.TestUpgradeVersionListOverrideAcceptsAnyNamecarries the real assertion, so this is acceptable for characterization — just noting the target is weaker than its length suggests.- 6 suggestion(s)/nit(s) flagged inline on specific lines.
Six more, from threads I had resolved before reading them. The bounded RunE call in TestStartAfterChainIDAgreementHitsTheGenesisNilDeref: the previous fix still relied on the nil-deref to stop RunE, so handling that discarded error would have made the test hang rather than fail. It now runs on a goroutine with a 20s bound and reports the reason. FuzzResolveAndCreateDirTildeExpansion remaps absolute inputs under the fixture instead of skipping them, so the absolute branch stays covered without provisioning real directories. Verified no /tmp pollution after a fuzz run. TestInitEnvDuplicatesTheEnvironmentUnderAnEmptyPrefix now runs under configtest.Isolate, which restores the environment InitEnv re-exports. Without it the assertion went vacuous on a second run in the same process, and every later test in the binary saw a doubled environment. Verified with -count=3. The package's pre-existing -count=2 failures are unrelated and reproduce without this file. TestGenesisDocWithoutTimeIsNotReproducible drops its 2ms sleep, which the sei-tendermint conventions ask tests to avoid, for the interval assertion the sibling fuzz target already uses. DumpViper and DumpViperKeys preallocate their line slices, and the hand-rolled itoa is replaced by strconv.FormatUint. All 55 fuzz targets swept clean again after these changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…us branches FuzzResolveAndCreateDirTildeExpansion filtered absolute inputs but not parent references, and MkdirAll follows ../../.. out of the fixture exactly as well as an absolute path does. A tilde form escaped too, since ~/../.. resolves above the pinned $HOME, and remapping cannot contain either because Join cleans the parent segments back out. Inputs containing a .. element are now rejected. Verified with a 20 second fuzz run leaving no stray directories. Three error branches returned early where the fixture cannot legitimately produce an error, so each was a silent pass-through: FuzzGenesisDocFromJSONTimeCompletion builds only well-formed documents, so a rejection is now a failure; FuzzWasmSimulationGasLimit restates the expectation against cast and asserts the resolved value rather than accepting any error. The two remaining global-viper tests now run under configtest.Isolate. Under an empty env prefix a bare TRACE on a CI runner is a config source, and InitEnv re-exports the environment without restoring it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The .. filter added in ca95652 worked but was both too strict and not airtight. filepath.IsLocal is the right check and this PR already uses it in sei-tendermint/privval/file_config_fuzz_test.go, so the guard now matches. Too strict: the element scan rejected a non-escaping interior reference such as a/../b, which is a legitimate input worth covering. IsLocal permits it and it is now seeded. Not airtight: an absolute path can carry .. segments that Join cleans back out, so Join(scratch, "/a/../../..") is "/" and the remap did not contain it. Containment is now re-checked after the join. Verified with a 25 second fuzz run leaving no directories outside the fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Test-only PR (35 new files, no production code) adding a shared testutil/configtest harness plus 54 fuzz targets and 79 characterization tests that pin the legacy seid config-resolution surface. The harness design is sound and the assertions are unusually well-reasoned; findings are all non-blocking — a few flakiness-prone or timing-dependent patterns, one dead-code bug in an unused helper, and some convention deviations in the sei-tendermint subtree.
Findings: 0 blocking | 15 non-blocking | 8 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's second-opinion file (
./cursor-review.md) is empty, so that review pass produced no output. Codex's file had two findings, both merged below. - No CI job actually fuzzes:
.github/workflows/go-test.ymlruns plaingo test -race, which only replaysf.Addseeds. Notestdata/fuzz/**corpora are committed either, so the ongoing value of the 54 targets rests entirely on the seeds plus manual-fuzzsweeps. Consider a nightly/scheduled-fuzzjob with a bounded-fuzztimefor at least theApply/section-reader targets. - CI runtime cost is worth measuring against the shard budget. Several targets do real work per seed:
FuzzApplyPrecedenceTendermint/FuzzApplyPrecedenceApp/FuzzApplyIsIdempotenteach run fullApplyboots with file I/O (idempotence runs three per seed),rootseam/startprerunbuild a freshNewRootCmd()per case,FuzzBaseAppConcurrencyWorkersconstructs BaseApps, andTestGigaExecutorEnabledDrivesLastResultsHashValidationbuilds two full apps. Under-raceplus coverage that is not free. - Dead harness API ships untested:
fuzzing.TOMLScalar,configtest.DumpViperKeys,AppOpts.With/Without/Clone,Home.ReadandCastIntSlicehave no callers in the tree. The PR description justifies this as groundwork for the differential harness, which is reasonable, but unexercised helpers drift (see the%gnote onTOMLScalar). configtest.Isolatedocuments that it does not save/restoreserver/config's package-global app.toml template orseilog's default level, and thecmd/seid/cmdtargets do mutate the template viainitAppConfig()+Applyon every call. Nothing asserted today depends on either, so this is fine now — but the gap is exactly the kind that turns into an execution-order-dependent failure later. Worth a follow-up that makes those two globals part ofIsolaterather than relying on the doc comment.AGENTS.mdisn't updated to mentiontestutil/configtestas the canonical harness for configuration tests, so the next contributor writing a config test has no pointer to it. A two-line note under the nested-guides or code-style section would be enough.- Checked against
REVIEW_GUIDELINES.md: the diff introduces no version-gated constants or upgrade-tag references, so neither §1 nor §2 applies. I also found no prompt-injection-style content in the diff, commit message, or PR body. - 8 suggestion(s)/nit(s) flagged inline on specific lines.
Superseded: latest AI review found no blocking issues.
…ghten seven nits Eight findings from the AI Review run against 09e2c48. The bounded RunE runner leaked a goroutine on the clean-return path: an explicit send filled the one-slot buffer and the deferred send then blocked for the life of the test binary. The deferred send now carries the result on its own, recover() being nil on a normal return is exactly the value callers check for. The runner also returns RunE's error instead of discarding it, without which a caller cannot tell "reached the row and returned" from "failed somewhere earlier". That distinction immediately mattered. Both RunE rows only worked once per process: start registers prometheus collectors on a global registry, so a second invocation failed with a duplicate-registration error before reaching either row's subject. Both now write a fixture app.toml with telemetry off, and are -count=3 -race clean. TestStartChainIDMismatchPanics called RunE inline with no bound, so a mismatch panic that stopped firing would have hung rather than failed. It uses the shared runner now. TOMLScalar rendered an integral float through %g, so float64(3) became "3" and decoded back as int64(3), breaking the round-trip contract for exactly the values ConfigValue generates. configtest.Pick panicked with a divide-by-zero on an empty manifest instead of saying so. The client-viper env-name row asserted the harness against a literal, which only proved the helper agrees with itself. It now sets the name and observes a real client viper resolve it, and checks that SEI_CHAIN_ID still does nothing alongside it. The nondeterminism row is renamed ...IsUnspecified and its message now says that a deterministic loop is the fix the sibling row argues for and names what to update. The genesis comment no longer claims a robustness the inequality does not have, and the mode rows say why they match on error text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ror-text assertions Two findings against cc96f5c. FuzzGlobalViperBareEnvVarsBecomeConfigSources never got configtest.Isolate. Three tests in that file were patched individually and the fuzz target was missed, which also makes a claim I left on the PR thread wrong: the file was not pinned in both directions. Isolate now lives inside withGlobalViper, so every row in the file gets it and none can be missed again, and the three per-test calls are gone. The mode and sink rows matched on error text, which sei-tendermint/AGENTS.md asks tests to avoid. They now assert only that the input is rejected, and the property those messages existed for is pinned separately: that an absent mode and a misspelled one report different failures, and that a repeated sink and an unrecognized one do too. Both survive any rewording. The two mistakes in each pair have different fixes, so an operator needs to be able to tell them apart, and asserting distinctness holds that without freezing the wording. Sentinel errors would be better still, but that means editing production code this PR does not touch. The remaining message assertions in cmd/seid/cmd, sei-db/config and sei-cosmos/server are outside that guide's scope, and there the message is the operator-legibility property being pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Test-only PR (7217 added lines, 0 production changes) adding a shared testutil/configtest harness plus 54 fuzz targets / 79 characterization tests that pin the legacy seid config-resolution surface. The harness is well-designed (no sei-chain imports in configtest, so no import cycles; type-carrying Dump is the right call), the manifest-as-data approach is sound, and I found no correctness blockers — only hygiene/maintainability notes, the main one being Codex's goroutine-leak-on-timeout in runEBounded.
Findings: 0 blocking | 8 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The Cursor second-opinion pass (
cursor-review.md) produced no output — the file is empty. Only Codex's single P1 was available to merge in, so this review is Claude + Codex only. - Seed indices are positional against the manifest tables (
configtest.Pick(evmKeys, keyIdx)withf.Add(uint(16), ...)etc.). The// deny_list/// float into a float keycomments on those seeds are correct today, but inserting a row anywhere inevmKeys/scKeys/ssKeys/ethReplayKeyssilently repoints every seed to a different key without failing anything. Consider deriving seeds from the row's index via a small helper (indexOfKey("evm.deny_list")) so a table edit can't quietly drop coverage of the shape the seed was written for. - Several genuine defects are pinned as required behavior and are recorded only in test comments — the
[state-store]zero-clobber (ss-keep-recent→ 0 = unbounded disk growth),unsafe-skip-upgradesbeing unsettable in app.toml at all, the nondeterministictx-index.indexerboot, andApplynever validating a pre-existingconfig.toml. That's the right call for a characterization suite, but these should be tracked as tickets rather than living solely ast.Fatalfprose, otherwise "pinned" and "accepted forever" become indistinguishable. - CI cost is non-trivial for a test-only diff: the sink file alone does ~2200
EventSinksFromConfigcalls, and eachcmd/seid/cmdfuzz seed builds a real root command and runs a fullApplywith filesystem materialization (FuzzApplyIsIdempotentis 3 boots per seed). Worth checking the affectedmake test-group-*shard's wall clock under-racebefore merge. - In-package tests in
sei-tendermint/config,sei-cosmos/baseapp,sei-cosmos/server/configandsei-db/confignow importgithub.com/sei-protocol/sei-chain/testutil/.... That's fine inside the single root module today (andconfigtestdeliberately imports no sei-chain package), but notetestutil/fuzzingdoes pull insei-cosmos/types— so the vendored subtrees' test builds now depend upward on the root module's testutil. If any of these subtrees is ever re-extracted, these test files move with it or break. - 3 suggestion(s)/nit(s) flagged inline on specific lines.
…the RunE timeout Four findings against 7e83ed3. TestServerEnvPrefixFollowsExecutableBasename only compared the two env-var spellings and concluded they differ, which says nothing about which one Apply reads. It now resolves a baseline with neither set, asserts the derived name takes effect, then clears it and sets the literal SEID_ name alone and asserts the address falls back to that baseline. Mutation-checked: pointing the second setenv at the derived name fails the row. runEBounded's 20s timeout called t.Fatal while a real node kept running. t.Fatal is runtime.Goexit on the test goroutine only, so state databases stayed open and RPC, P2P and gRPC listeners stayed bound for the rest of the binary, reading the environment while configtest.Isolate's cleanup cleared and restored it. newStartCmd now builds the command context with cancel and returns it, and the timeout path cancels, waits a bounded 10s, and reports whether the node actually stopped. Cancelling is a request, so the message distinguishes the two outcomes rather than claiming a clean stop. The genesis distinctness assertion needed the wall clock to advance between two parses microseconds apart, making it the one row whose passing depended on clock granularity rather than on the code. It now retries across ticks, so a coarse platform costs attempts instead of a false failure, and a genuinely deterministic completion still fails however many attempts it gets. Mutation-checked. The second read's lower bound is also tightened from beforeFirst to afterFirst, which orders the two reads without needing the clock to move at all. The sink rows ran 1000, 400 and 400 iterations. The observed split is roughly 168 boots to 32 failures per 200 runs, so 250 puts the odds of missing the minority branch below 1e-18 and the rest was race-shard time. The comment now also states that the randomization is unspecified: nothing promises Go randomizes a two-element map, so if that ever changes the row fails for a reason unrelated to sei, and the message covers that reading. Not taken: the report that filepath.Join discards the scratch root for an absolute second argument. That is Python and Rust join semantics. Go's Join concatenates and cleans, so Join(scratch, "/sei") is scratch + "/sei", the containment check does not return early, and the absolute seed reaches the resolver inside the fixture. Verified directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
A large, test-only PR (7,294 added lines, no production changes) that turns the legacy seid configuration surface into 54 Go-native fuzz targets plus characterization tests, backed by a well-designed hermetic harness in testutil/configtest. I found no correctness blockers; the remaining notes are about intentional-but-fragile nondeterminism assertions, CI wall-clock cost, and a couple of assertions coupled to values that could drift.
Findings: 0 blocking | 7 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Second-opinion coverage was partial:
cursor-review.mdis empty, so the Cursor pass produced no output for this PR. Codex produced a single P1 (the sink nondeterminism tests), which is merged into the inline comments below. - CI wall-clock is worth measuring before merge. Several rows are loop-heavy or boot-heavy:
sink_fuzz_test.goruns 4×250EventSinksFromConfigcalls,TestGenesisDocWithoutTimeIsNotReproduciblecan run up to 1000 parse pairs,TestGigaExecutorEnabledDrivesLastResultsHashValidationconstructs two full apps, and mostcmd/seid/cmdtargets do 1–3 fullApplyboots per seed with real filesystem writes and full env clear/restore. Under-racewith coverage this is a meaningful shard delta. Please report the measured before/after for the affectedmake test-group-Nshards; if it's large, consider gating the highest-iteration loops behindtesting.Short(). configtest.Isolateexplicitly does not restore the two package-globalsInterceptConfigsPreRunHandlerwrites (server/config.SetConfigTemplateandseilog.SetDefaultLevel). That is documented honestly, but this PR takes the number of tests incmd/seid/cmdthat mutate them from ~0 to dozens, including tests that deliberately set an unparseable/overridden log level. Adding the save/restore to the harness now is cheaper than debugging the first order-dependent failure it causes.testutil/configtestis a non-test package that importstesting; that is a normal testutil pattern here, but it means anything that ever imports it linkstestingin. Worth a one-line note in the package doc that it is test-only, since the doc otherwise reads like a general-purpose helper.- 3 suggestion(s)/nit(s) flagged inline on specific lines.
…p-order rows Three findings against 587740b. The two null-ordering rows assert that both branches of Go's map-iteration randomization occur, which rests on behavior the runtime does not promise. A future Go that iterates two-element maps deterministically would turn both red for a reason unrelated to this repository. Rather than degrade the assertion to a log or hide it behind a guard, the premise is now measured: a probe map of the same shape is ranged the same number of times, and only if its starting key never varies does the row skip, naming the runtime as the cause. If the probe still varies, a consistent sink result is sei's own change and the failure stands. Both branches are mutation-checked. Making selection deterministic in sink.go fails both rows with the message that asks for them to be rewritten; making the probe deterministic as well turns that into a skip. Keeping the failure matters more than the flake math here, which was already fine at 250 runs. The message is what tells whoever lands a deliberate fix that these two rows encode the old behavior and need updating. FuzzApplyEnvReachesTheStructOnlyForStructurallyKnownKeys exempted the literal "simple-priority" because it is p2p.queue-type's current default, so a fuzzer generating the default would otherwise trip the assertion. That coupled the row to one spelling. The exemption now comes from tmcfg.DefaultConfig(). Verified by moving the default to "fifo": the derived form passes, and the literal form fails with "the environment reached serverCtx.Config ... (P2P.QueueType = string("fifo"))", which is the spurious failure the report predicted. runEBounded's bounds drop from 20s and 10s to 5s each, cutting the worst case from 30s to 10s per row. Both rows are stopped by a panic that fires in well under a second, so the bound only has to outlast a loaded -race shard. 5s rather than the 2-3s suggested, since the cost of being too tight is a false report that the guard stopped guarding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Test-only PR (7,343 added lines, zero production changes) that turns the legacy seid configuration surface into a hermetic harness (testutil/configtest, testutil/fuzzing/config.go) plus 54 fuzz targets and 79 characterization tests. The work is unusually careful — each pinned behavior restates its rule independently of the implementation and explains what a future change would mean — and I found no blocking correctness or security problems; the notes below are hardening and maintainability suggestions.
Findings: 0 blocking | 9 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's second-opinion pass produced no output (
cursor-review.mdis empty), so this review merges only Claude's and Codex's findings. Codex contributed one P2 (theCheckKeysingle-leaf oracle), which I agree with and have raised inline. - New coupling into the vendored subtrees: this PR adds the first imports of
github.com/sei-protocol/sei-chain/testutil/configtestfromsei-cosmos/,sei-tendermint/,sei-db/andsei-wasmd/(verified — no such imports existed before). Those trees are single-module today, so it compiles, butsei-tendermint/AGENTS.mdtreats sei-tendermint as "a root of the go module, but not the root of the repo". If any subtree is re-synced from upstream or split back out, these eight test files break. Worth an explicit decision: accept the coupling and note it, or keep a small duplicate of theAppOpts/Dumpprimitives inside each subtree. - CI budget: 54 fuzz targets replay their seed corpora on every
go test -racerun, and several tests do heavy repeated work —TestNullMixedWithAnUnsupportedSinkIsUnspecified,TestNullSinkAlwaysWinsOverARecognizedSinkandTestNullSinkCanOpenAnIndexStoreItThenDiscardseach loop 250–500 times,TestGenesisDocWithoutTimeIsNotReproduciblemay loop 1000 times, andTestGigaExecutorEnabledDrivesLastResultsHashValidationbuilds two full apps. Worth confirming the affected shards' wall-clock before/after so a shard timeout doesn't surface later as an unrelated-looking CI failure. - The six previously-undocumented behaviors the fuzzing surfaced (first-boot
SEID_*inertness, theuint64flag string passthrough,unsafe-skip-upgradesbeing unsettable in app.toml, the missingValidateBasicon a pre-existing config.toml, nondeterministic tx-index sink selection, andInitEnv's environment duplication) currently live only in Go doc comments and the PR body. Since they are operator-facing footguns rather than test trivia, consider mirroring them into a doc the config-manager work can link to, so they survive a future refactor of these files. - 5 suggestion(s)/nit(s) flagged inline on specific lines.
…ominated leaf Six findings against ad864d8. CheckKey asserted only the dump line at spec.Path, so a reader that resolved the nominated key correctly and also perturbed an unrelated field passed. That is the failure mode a characterization oracle exists to catch: the manifest's claim is that one key moves one field, so the assertion has to cover the fields it says do not move. Rows now compare the whole document, built by splicing the expected leaf into the baseline view, which keeps each row's prediction to the one leaf it owns. A reader with legitimate fan-out declares the extra paths in a new KeySpec.AlsoWrites, so the fan-out stays recorded in the manifest instead of absorbed by a weaker assertion. No existing row needed AlsoWrites: all 87 pass unchanged, and every CheckRow fuzz target was swept. Since a strengthened assertion that catches nothing is worth nothing, it was mutation-checked both ways. Making the eth_replay enabled read also write EthDataDir fails the row, and declaring EthDataDir in AlsoWrites makes it pass again. The failure message on the read-must-succeed path could lie. It was reached whenever err != nil, including an unchecked row whose conversion failed, where it claimed the value "converts cleanly". The wording now branches on castErr. Flag values were applied by ranging a map, so the order differed per run. Nothing depends on it today, but a fuzz corpus is only useful if a failing entry reproduces, and the first row whose flags interact would have failed intermittently against the seed that found it. Both harnesses now apply flags in sorted key order through one shared helper. runEBounded's escaped-node path was best-effort in a way the message did not own. After the cancel and the second wait, t.Fatal unwinds one goroutine, so a node that ignored the cancel would keep its listeners bound and be inherited by every later test, including through Isolate's environment clear and restore. That path now panics, which fails the binary rather than leaving a corrupted one producing results nobody should trust. The map-order probe detects total loss of randomization, not skew, so a split pushed from the observed 16% to a fraction of a percent would fail intermittently rather than skip. The probe's own split is now reported, which is what distinguishes the two. Partly not taken: the report that the giga row's missing Isolate lets an ambient UPGRADE_VERSION_LIST rewrite upgradesList for later tests. The mechanism is real and the row now pins the environment and restores the list, but the attribution is wrong and the row was never the trigger. testutil/keeper's package init calls app.SetupWithDefaultHome, so an app is built and overrideList runs before any test body in this binary. Verified by stack trace. The variable is already applied before any row could pin it, and nothing in this file can prevent that. The comment says so rather than implying the leak is closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 649a2e9. Configure here.
There was a problem hiding this comment.
Test-only PR adding a shared config-characterization harness (testutil/configtest, testutil/fuzzing/config.go) plus 54 fuzz targets and 79 characterization tests across 20 packages; no production code changes. The work is unusually well-documented and internally consistent — I found no blocking correctness or security issues, only CI-robustness and process notes.
Findings: 0 blocking | 10 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's second-opinion pass produced no output (
cursor-review.mdis empty), so this review merges only Claude's and Codex's findings. - Re Codex P2: the citation is misapplied.
cmd/seid/cmd/startprerun_config_fuzz_test.gois not undersei-tendermint/, so the structured-concurrency / no-artificial-timeouts rules insei-tendermint/AGENTS.mddo not govern it (the rootAGENTS.mdhas no equivalent rule). The underlying robustness concern about the 5s+5s bound is still worth addressing — see the inline comment on line 305. - Re Codex P1: partially disagree on severity. At the documented ~84/16 split, 250 runs put the flake probability around 1e-19, and
requireSmallMapRandomizationalready converts "runtime stopped randomizing" into a skip rather than a red shard. The residual risk (probe detects total loss, not skew) is acknowledged in the file itself; see the inline nit. - Several new tests under
sei-tendermint/compareerr.Error()strings (TestEventSinksDistinguishesADuplicateFromAnUnsupportedName,TestValidateBasicDistinguishesAnAbsentModeFromAnUnknownOne), whichsei-tendermint/AGENTS.mdasks tests to avoid. The comments explain why (the production sites build bareerrors.New/fmt.Errorfwith no identity to match on, and this PR deliberately touches no production code) and the assertions are on distinctness rather than wording, which is the right compromise. Worth filing the follow-up to add sentinel errors so the deviation doesn't become permanent. - CI only replays the
f.Addseed corpora — nothing runs-fuzz. The PR's own value came from sweeping under-fuzz(six undocumented behaviors found), and that exploration stops the moment this merges. Consider a scheduled/nightly fuzz workflow over these targets, or the suite degrades into a fixed set of table tests. - Fixed-cost loops added to every CI run: 250 iterations x3 in
sink_fuzz_test.go(750EventSinksFromConfigcalls), up to 1000 retry pairs inTestGenesisDocWithoutTimeIsNotReproducible, and realapp.Newconstructions inTestGigaExecutorEnabledDrivesLastResultsHashValidation. All cheap individually, but they are pure overhead on the happy path — worth confirming the added wall-clock against the existing shard budgets. - No
AGENTS.mdor README undertestutil/configtest, and the rootAGENTS.mdisn't updated to point at the harness. The package doc comment is thorough, but a one-line pointer from the top-level guide would make it discoverable to the next author (and to the forthcoming differential harness that is meant to consume it). - 3 suggestion(s)/nit(s) flagged inline on specific lines.
…nst real artifacts Two findings against 649a2e9, both the vacuity class this PR has now hit five times: an assertion that reads back a constant the test supplied rather than an artifact the node produced. The wasm query-gas row declared a local 300000 and fed it to ReadWasmConfig, so it proved the reader echoes its input. The template literal it claimed to pin lives in cmd/seid/cmd/root.go, where query_gas_limit is one of the few keys written as a bare number rather than a {{ .Field }} substitution, and the test never read it. Editing that literal left the row green. The template half now lives in cmd/seid/cmd, where the template is reachable: a fixture home is materialized through Apply and the row reads what the generated app.toml actually resolves. The constant stays, but it is now an expectation compared against a real artifact instead of an echo. Bumping the literal to 400000 fails the new row and leaves the old one passing, which is the drift the report described; closing the divergence to 3000000 fails it too. The wasm row keeps the half that package genuinely owns, that an absent section resolves the in-code default and that it stays above the generated limit, and its comment says where the other half is pinned and why it cannot be pinned there. The manager-gate row asserted read-once by exercising Select in isolation, then setting the variable after PersistentPreRunE had returned and checking an already-held viper pointer was unchanged. Nothing reads the gate after the call completes, so no implementation could fail that half. It now runs two invocations in one process with different values between them. The first boots legacy, the second must reach the v2 stub and fail. That is falsifiable in the direction that matters: memoizing Select across invocations makes the second boot cleanly and fails the row, verified by adding such a cache. It also pins the opposite error from the first half, since a gate read once per process would pin every command to what the first one saw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
A large (7.5k line) test-only PR that turns the legacy seid configuration surface into Go-native fuzz targets and characterization tests; the harness (testutil/configtest) is careful, well-documented, and changes no production code. No blocking defects found — the notes below are a latent bug in the currently-unused TOMLScalar helper, a couple of CI-robustness concerns, and small cleanups.
Findings: 0 blocking | 9 non-blocking | 5 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The Cursor second-opinion pass (
cursor-review.md) is empty — that reviewer produced no output for this PR. Codex's pass did produce findings and they are merged below. - CI cost / flake surface: several rows deliberately depend on runtime nondeterminism — three 250-iteration loops in
sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.gothat require Go's map-iteration randomization, and a 1000-attempt clock loop insei-tendermint/types/genesis_config_fuzz_test.go. TherequireSmallMapRandomizationprobe correctly skips on total loss of randomization, but (as its own comment notes) it does not detect skew, so a shift in the observed ~16% split would surface as intermittent red rather than a skip. Worth watching once these land on the shards. configtest.Isolatedocuments that it does not restore two package globals the legacy read path mutates (config.SetConfigTemplateandseilog.SetDefaultLevel). The newcmd/seid/cmdtargets do exercise both (everyapplyLegacyrenders the template; the loglevel rows drive level resolution), so thecmd/seid/cmdbinary now carries latent execution-order dependence. Nothing asserted today depends on it, but a save/restore in the harness would close the class rather than relying on future authors reading the doc comment.testutil/fuzzing.TOMLScalarhas no callers anywhere in the tree — it exists for the forthcoming differential harness. That is a reasonable staging choice, but it means its two correctness gaps (see inline comments) are currently unexercised and will only bite the next PR.- 5 suggestion(s)/nit(s) flagged inline on specific lines.
| case nil: | ||
| return "", false | ||
| case string: | ||
| return fmt.Sprintf("%q", t), true |
There was a problem hiding this comment.
[suggestion] %q renders Go escapes that TOML basic strings do not accept: control bytes become \x01, \v is a Go escape with no TOML equivalent, and invalid UTF-8 becomes \xNN. TOML permits only \b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX, so for those inputs this returns a document the parser rejects while reporting ok=true — the caller then attributes a parse failure to the layer under test.
Since the function's whole purpose is to drive the same value through the file layer and the appOpts layer and compare, that would surface as a spurious differential. Either gate on configtest.IsTOMLWritable-style criteria and return ok=false, or emit the TOML-legal escape set explicitly. (Raised by Codex; confirmed by reading. Currently latent — TOMLScalar has no callers yet.)
| case int64: | ||
| return fmt.Sprintf("%d", t), true | ||
| case uint64: | ||
| return fmt.Sprintf("%d", t), true |
There was a problem hiding this comment.
[suggestion] TOML integers are signed 64-bit, so any uint64 above math.MaxInt64 renders text the parser rejects while this still reports ok=true. ConfigValue kind 5 produces exactly that shape on purpose (uint64(n) with the sign bit set, "a value near 2^64"), so the fuzzer will reach it as soon as this helper is wired up. Suggest if t > math.MaxInt64 { return "", false }. (Raised by Codex; confirmed.)
| select { | ||
| case r := <-outcome: | ||
| return r.recovered, r.err | ||
| case <-time.After(5 * time.Second): |
There was a problem hiding this comment.
[suggestion] The 5s bound is justified in the doc comment against a panic that fires "in well under a second", but the failure mode on a loaded -race shard is expensive: the first timeout turns into a t.Fatalf that reads as "the guard stopped guarding" (a misleading diagnosis for what is really scheduler pressure), and the nested path deliberately panics the whole test binary. Since newStartCmd runs a full root PersistentPreRunE plus RunE up to the genesis deref, that is more work than the comment implies.
Consider raising the first bound (30s) or deriving it from the remaining -timeout budget via t.Deadline(); the guard keeps its meaning and the false-positive cost drops a lot.
| t.Fatalf("both an absent and an unknown mode must be rejected, got %v and %v", | ||
| absentErr, unknownErr) | ||
| } | ||
| if absentErr.Error() == unknownErr.Error() { |
There was a problem hiding this comment.
[nit] Codex flagged this (and the sibling at sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go:256) against the nested sei-tendermint/AGENTS.md rule "avoid checking human readable error messages in tests". I'd rate it lower than Codex did: these compare the two errors to each other rather than pinning either one's wording, so they survive any rewording, and the doc comment already explains that the production sites build bare errors.New/fmt.Errorf values with no identity to match on and that adding sentinels would mean touching production code this PR deliberately avoids.
Still worth filing the sentinel-error follow-up explicitly so the exemption doesn't quietly become precedent — right now the justification lives only in these two comments.
|
|
||
| // applyThrough runs the legacy manager against a command from newApplyCommand, using | ||
| // the node's real template and config struct. | ||
| func applyThrough(cmd *cobra.Command, _ *server.Context) error { |
There was a problem hiding this comment.
[nit] applyThrough ignores its *server.Context parameter entirely (the context reaches Apply through cmd's context, set in newApplyCommand). Two of the three call sites pass a value only to satisfy the signature. Dropping the parameter would remove the implication that the context is threaded here.
…nk skew Three findings against 81e99e5. The panic added last round aborts the whole package, and the trigger was a wall-clock bound rather than evidence a node is running, so a slow shard could destroy every other result in the binary. That inverted the cost of being wrong and the bound was still sized for the old calculus. It is now 30s with a 10s cancel grace, both named constants: a bound that is too long costs only a delayed report on a path where something is already broken, since the happy path returns as soon as the panic fires and never waits, while one that is too short aborts a healthy package. A final non-blocking receive runs before the terminal branch, so a RunE that completed in the window between the grace period expiring and the panic is reported as an ordinary failure rather than treated as a stranded node. This reverses the 20s to 5s reduction from the round before. The earlier reasoning was right when the failure was a t.Fatal and wrong once it became terminal. requireSmallMapRandomization detected total loss of randomization but not skew, so a minority branch collapsing toward zero would fail intermittently while the message blamed a deliberate fix. When the probe still yields both keys it now fails with a diagnosis naming both readings that fit at this run count, since selection becoming deterministic and its split collapsing are indistinguishable over 250 runs and have different fixes. The caller's message no longer asserts which one happened. The probe's split is reported rather than characterized: it runs around 90/10 for a two-element map, which answers whether order varies and is not a yardstick for the sink's own split. The Isolate allowlist drops SSL_CERT_FILE, SSL_CERT_DIR, the XDG_* group and USER with LOGNAME. Nothing pinned needs them, so rather than widen the list the exclusion is named in the doc comment with what to do about it, because every entry added is a name the empty-prefix viper can then resolve as a config value and that cost is what keeps the list short. Unrelated, found while verifying and not caused here: app's TestEvmAnteErrorHandler fails under -shuffle=on, 3 runs in 5 on origin/main with none of this branch present. It shares the process-global testkeeper.EVMTestApp and reads deferred tx info another test can leave in a different state. CI does not shuffle and the package is green as CI runs it, so this is a pre-existing flake to track separately rather than a regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Describe your changes and provide context
Phase 1 of the SeiConfigManager work. A Sei node assembles its configuration from
config.toml,app.toml, cobra flags and environment variables, resolved across three separate viper instances with three different env prefixes. None of that resolution is written down in a form a second implementation can be measured against, so backwards compatibility for the new manager would come down to assertion. This PR turns the load-bearing part of the design's Appendix D manifest into Go-native fuzz targets and characterization tests. It changes no production code.Harness
testutil/configtestis the shared harness, and the forthcoming differential harness will consume it as well. It has four pieces:Isolatepins the process environment and$HOME. The legacy path runs a viper with an empty env prefix, so a stray variable on a developer's machine can become a resolved config value, and$HOMEoutranks the--homeflag default in that same viper.Homebuilds fixture node directories, because the read path writes files as a side effect of reading them.Dumprenders a resolved view as diff-friendly text carrying each leaf's concrete Go type. The string"5"and the int5cast differently downstream, so a dump that printed both as5would hide the type divergence this suite is meant to catch.KeySpecandCheckRowmake each manifest row checkable, carrying the key, the field it resolves into, the cast applied, and whether the read is guarded and checked.testutil/fuzzing/config.godecodes fuzzer scalars into the value shapes viper actually produces, following the conventioncosmos.goalready set.Coverage
54 fuzz targets and 79 characterization tests, grouped by what they hold constant:
Apply's four layers, both boot channels, all three viper universes, malformed-TOML totality and materialization idempotence, plus the seam itself, meaning which subcommands getApplyand howSEI_CONFIG_MANAGERis selected at its real call site in the rootPersistentPreRunE.appOptsreader,baseapp's four construction-time reads, the secondapp.tomlparser inserver/config.GetConfig, andstart'sPreRunEpruning fail-fast and chain-id mismatch panic.UPGRADE_VERSION_LISTreplacing the upgrade-handler list,giga_executor.enableddrivingSkipLastResultsHashValidation,hash-vault-disabled-unsafe's root scope,privvalload-or-generate, the genesis reader's time completion,upgrade-info.json, the database and consensus-WAL layout fallbacks, tx-index sink selection and sei-db's tilde expansion.That covers roughly 54% of Appendix D's 235 rows fully. The
appOptscontract is whatapp.Newconsumes and what the new manager has to reproduce, and it sits at 73 of 87. 8 of the design's 10 sharp edges are covered.Legacy defects are recorded as pinned behavior rather than fixed, since changing them is a migration. That includes the
[state-store]zero-clobber, the inert template keys and the panic-on-non-stringgenesis.import-fileread.Undocumented Behaviors
Fuzzing surfaced six behaviors that were not written down anywhere. They fall into three groups.
Resolution order that changes between boots.
SEID_*for a template-rendered, flag-less key is inert on a node's first start and effective on every restart. The branch that createsconfig.tomlnever reads it back, while the branch that finds one does.Uint64cobra flag'sapp.tomlinteger reachesappOpts.Getas a string.bindFlagscopies the resolved value into the flag, and viper's pflag switch convertsint*andboolwhile passinguint64through as text. Every downstreamcast.To*absorbs it, which is why it stays invisible until a differential compares types.Boot failures with no diagnostic pointing at config.
unsafe-skip-upgradesinapp.tomlfails the boot.bindFlagsrenders the slice through%vas[100 101], pflag's int-slice parser rejects it, and the panic surfaces through the swallowedrecover. Even[]fails, so there is no value an operator can persist in the file, which is the place a skip that must survive a restart belongs.Applynever validates a pre-existingconfig.toml.ValidateBasicruns only on the creation branch, somode = ""boots clean and fails later fromnode.New.Nondeterminism from map iteration.
tx-index.indexerholding"null"plus an unrecognized name resolves nondeterministically, 168 boots and 32 failures over 200 runs, because sink selection ranges over a map. With"null"pluskvthe result is stable, though atx_indexstore is opened and orphaned in roughly one run in eight.InitEnv's underscore-prefixed duplicates are themselves resolvable viper keys, so the global viper's key space is the whole environment twice over.One manifest row turned out stale rather than uncovered.
sc-keys-to-migrate-per-blockis no longer anapp.tomlkey, having moved to a governance param read from chain state. That is the drift the design's surface-freezing gate is meant to catch.Out of Scope
Three groups, named rather than left implied.
keys,queryandtx,genaccounts,gentx,export,rollback,debug dump,lightandreset.go.mod, along with benchmark and simulation build-tag paths and DB-identity files.startInProcess(cpu-profile,trace-store, the secondGetConfig,grpc-onlyforcingGRPC.Enable, the api and grpc-web gating),start.go:180's genesis nil-deref, the reads inline innewApp, and the node-lifecycle path (node.go,public.go,setup.go, the hash-vault construction).The third group belongs to an integration harness rather than a test-only diff, and I would track it separately.
Testing performed to validate your change
-count=1, including each package's pre-existing tests. That coversapp,cmd/seid/cmd, sei-cosmos (server,server/config,baseapp,x/upgrade/keeper), sei-tendermint (config,libs/cli,privval,types,internal/state/indexer/sink), sei-db (config,common/utils),sei-wasmd/x/wasm,admin,evmrpc/config,giga/executor/config, x/evm (querier,replay,blocktest) andtestutil.-fuzz, beyond replaying their seed corpora, and are clean. Several of the failures that surfaced were real and are fixed here, and the interesting inputs are kept as explicitf.Addseeds with a comment saying why. Two review findings were ones that sweep would have caught, and had not been fuzzed when the PR first went up.gofmt -s -l .andgoimports -lare clean on every file in the diff,go build ./...is clean, andgolangci-lint runover all touched packages reports issues only in pre-existing files this PR does not modify, verified per file againstgit status.Reviewed through
/xreviewas a shared-stack T3 change, withsystems-engineerholding assigned dissent alongsidesecurity-specialist,sei-network-specialistandidiomatic-reviewer, all reviewing blinded and independently. The ledger is committed tobdchatham-designsatdesigns/config-manager/xreview/phase1-legacy-config-fuzz-characterization.md. The dissenter's lead objection was a comment documenting a negative into an unsigned cast as wrapping whencastactually rejects it, which I verified empirically and corrected in three places.The manifest rows hold as executable assertions, fuzzing found six behaviors nobody had written down, and the
appOptscontract sits at 73 of 87 rows. That leads me to lean towards this being enough of the surface to build SeiConfigManager against, with the integration-harness rows tracked separately.🤖 Generated with Claude Code