Skip to content

test(config): characterize the legacy configuration surface with native fuzzing (PLT-775) - #3816

Open
bdchatham wants to merge 16 commits into
mainfrom
test/plt-775-legacy-config-characterization
Open

test(config): characterize the legacy configuration surface with native fuzzing (PLT-775)#3816
bdchatham wants to merge 16 commits into
mainfrom
test/plt-775-legacy-config-characterization

Conversation

@bdchatham

@bdchatham bdchatham commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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/configtest is the shared harness, and the forthcoming differential harness will consume it as well. It has four pieces:

  1. Isolate pins 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 $HOME outranks the --home flag default in that same viper.
  2. Home builds fixture node directories, because the read path writes files as a side effect of reading them.
  3. Dump renders a resolved view as diff-friendly text carrying each leaf's concrete Go type. The string "5" and the int 5 cast differently downstream, so a dump that printed both as 5 would hide the type divergence this suite is meant to catch.
  4. KeySpec and CheckRow make 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.go decodes fuzzer scalars into the value shapes viper actually produces, following the convention cosmos.go already set.

Coverage

54 fuzz targets and 79 characterization tests, grouped by what they hold constant:

  1. Resolution machinery. Apply's four layers, both boot channels, all three viper universes, malformed-TOML totality and materialization idempotence, plus the seam itself, meaning which subcommands get Apply and how SEI_CONFIG_MANAGER is selected at its real call site in the root PersistentPreRunE.
  2. Section readers. Every appOpts reader, baseapp's four construction-time reads, the second app.toml parser in server/config.GetConfig, and start's PreRunE pruning fail-fast and chain-id mismatch panic.
  3. Config that reaches consensus or disk layout. UPGRADE_VERSION_LIST replacing the upgrade-handler list, giga_executor.enabled driving SkipLastResultsHashValidation, hash-vault-disabled-unsafe's root scope, privval load-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 appOpts contract is what app.New consumes 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-string genesis.import-file read.

Undocumented Behaviors

Fuzzing surfaced six behaviors that were not written down anywhere. They fall into three groups.

Resolution order that changes between boots.

  1. SEID_* for a template-rendered, flag-less key is inert on a node's first start and effective on every restart. The branch that creates config.toml never reads it back, while the branch that finds one does.
  2. A Uint64 cobra flag's app.toml integer reaches appOpts.Get as a string. bindFlags copies the resolved value into the flag, and viper's pflag switch converts int* and bool while passing uint64 through as text. Every downstream cast.To* absorbs it, which is why it stays invisible until a differential compares types.

Boot failures with no diagnostic pointing at config.

  1. unsafe-skip-upgrades in app.toml fails the boot. bindFlags renders the slice through %v as [100 101], pflag's int-slice parser rejects it, and the panic surfaces through the swallowed recover. 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.
  2. Apply never validates a pre-existing config.toml. ValidateBasic runs only on the creation branch, so mode = "" boots clean and fails later from node.New.

Nondeterminism from map iteration.

  1. tx-index.indexer holding "null" plus an unrecognized name resolves nondeterministically, 168 boots and 32 failures over 200 runs, because sink selection ranges over a map. With "null" plus kv the result is stable, though a tx_index store is opened and orphaned in roughly one run in eight.
  2. 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.

One manifest row turned out stale rather than uncovered. sc-keys-to-migrate-per-block is no longer an app.toml key, 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.

  1. Client and operator tooling with its own config universe, covering keys, query and tx, genaccounts, gentx, export, rollback, debug dump, light and reset.
  2. cosmovisor, which is a separate go.mod, along with benchmark and simulation build-tag paths and DB-identity files.
  3. Reads that need a running node. Those are the reads inside unexported startInProcess (cpu-profile, trace-store, the second GetConfig, grpc-only forcing GRPC.Enable, the api and grpc-web gating), start.go:180's genesis nil-deref, the reads inline in newApp, 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

  1. All 20 touched packages pass with -count=1, including each package's pre-existing tests. That covers app, 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) and testutil.
  2. All 55 fuzz targets were swept under -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 explicit f.Add seeds 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.
  3. gofmt -s -l . and goimports -l are clean on every file in the diff, go build ./... is clean, and golangci-lint run over all touched packages reports issues only in pre-existing files this PR does not modify, verified per file against git status.

Reviewed through /xreview as a shared-stack T3 change, with systems-engineer holding assigned dissent alongside security-specialist, sei-network-specialist and idiomatic-reviewer, all reviewing blinded and independently. The ledger is committed to bdchatham-designs at designs/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 when cast actually 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 appOpts contract 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

…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>
@cursor

cursor Bot commented Jul 27, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Test-only changes with no runtime behavior modifications; risk is limited to CI time and maintenance of large fuzz corpora, though tests document security- and consensus-sensitive config paths.

Overview
Adds executable characterization of how a Sei node resolves configuration, with no production code changes. The work is aimed at Phase 1 of SeiConfigManager: a second implementation can be compared against the same contracts.

New fuzz and unit tests cover section readers (admin, app SeiDB/genesis/light invariance, evmrpc, giga_executor, receipt-store, baseapp, server/config.GetConfig, pruning), boot seams (LegacyConfigManager.Apply, bindFlags, chain-id from client.toml, log-level env split, root PersistentPreRunE, start PreRunE/RunE), and operational edges (upgrade list override, giga executor → consensus hash check, upgrade-info on disk, path tilde expansion).

Tests use shared testutil/configtest / fuzzing helpers (CheckRow, Isolate, fixture homes, applyLegacy) and encode current behavior as the contract, including known quirks (e.g. unguarded [state-store] zero-clobber, unsafe-skip-upgrades in app.toml breaking boot, pre-existing config.toml skipping ValidateBasic, env-only keys inert on first boot).

Reviewed by Cursor Bugbot for commit 5350253. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 27, 2026, 11:34 PM

Comment thread sei-cosmos/server/config/config_fuzz_test.go
Comment thread sei-db/common/utils/path_fuzz_test.go
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.03513% with 192 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.44%. Comparing base (beac9b8) to head (81e99e5).

Files with missing lines Patch % Lines
testutil/configtest/keyspec.go 46.80% 84 Missing and 16 partials ⚠️
testutil/fuzzing/config.go 42.62% 35 Missing ⚠️
testutil/configtest/configtest.go 6.66% 14 Missing ⚠️
testutil/configtest/dump.go 82.66% 9 Missing and 4 partials ⚠️
testutil/configtest/home.go 62.85% 11 Missing and 2 partials ⚠️
testutil/configtest/viper.go 43.75% 8 Missing and 1 partial ⚠️
testutil/configtest/env.go 78.37% 4 Missing and 4 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
sei-chain-pr 67.11% <55.03%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
testutil/configtest/env.go 78.37% <78.37%> (ø)
testutil/configtest/viper.go 43.75% <43.75%> (ø)
testutil/configtest/dump.go 82.66% <82.66%> (ø)
testutil/configtest/home.go 62.85% <62.85%> (ø)
testutil/configtest/configtest.go 6.66% <6.66%> (ø)
testutil/fuzzing/config.go 42.62% <42.62%> (ø)
testutil/configtest/keyspec.go 46.80% <46.80%> (ø)

... and 145 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:78 and :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 in go test they only execute their seed corpora. The PR body says each target was fuzzed manually for 15–40s; consider a scheduled/nightly -fuzz job (or committed testdata/fuzz corpora) 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:66 re-derives strings.FieldsFunc + semver.Sort exactly as overrideList() does, and cmd/seid/cmd/startprerun_config_fuzz_test.go:108 compares cmd.PreRunE's verdict against the very GetPruningOptionsFromFlags(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 restore config.SetConfigTemplate or seilog.SetDefaultLevel. Since applyThrough calls initAppConfig() and Apply sets the log level, a target that resolves log_level=debug leaves the whole cmd/seid/cmd binary at debug afterwards. Harmless today (nothing asserts on level) but it makes the package order-dependent the moment something does — a save/restore in Isolate would close it now rather than later.
  • app/consensus_config_fuzz_test.go:121 registers t.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.

Comment thread sei-db/common/utils/path_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread sei-tendermint/libs/cli/setup_config_fuzz_test.go Outdated
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go
Comment thread sei-cosmos/server/config/config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/rootseam_config_fuzz_test.go Outdated
Comment thread sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go
…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>
Comment thread evmrpc/config/config_fuzz_test.go
Comment thread sei-cosmos/baseapp/config_fuzz_test.go
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>
Comment thread cmd/seid/cmd/loglevel_config_fuzz_test.go
…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>
Comment thread sei-cosmos/x/upgrade/keeper/upgradeinfo_config_fuzz_test.go
Comment thread sei-db/common/utils/path_fuzz_test.go
…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>
Comment thread testutil/configtest/viper.go Outdated
Comment thread cmd/seid/cmd/chainid_config_fuzz_test.go
Comment thread sei-cosmos/x/upgrade/keeper/upgradeinfo_config_fuzz_test.go
Comment thread sei-db/common/utils/path_fuzz_test.go Outdated
seidroid[bot]
seidroid Bot previously requested changes Jul 27, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md is empty), so that perspective is missing from this review.
  • Nothing in CI runs -fuzz, so all 54 targets only exercise their f.Add seeds. That is exactly why the broken FuzzReadUpgradeInfoFromDisk property survives review, and it means the suite's fuzzing value decays silently. Consider a nightly/scheduled -fuzz job over the touched packages, or committing the interesting inputs as testdata/fuzz corpus 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, and fuzzing.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 one go.mod, but it adds a new coupling direction that will complicate any future upstream sync or module split. Worth a brief note in testutil/configtest's doc comment about that tradeoff (it currently only explains why it imports nothing).
  • TestNullSinkCanOpenAnIndexStoreItThenDiscards records a real production resource leak: with indexer = ["null","kv"], EventSinksFromConfig opens a tx_index store 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.

Comment thread sei-cosmos/x/upgrade/keeper/upgradeinfo_config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread sei-db/common/utils/path_fuzz_test.go
Comment thread sei-tendermint/libs/cli/setup_config_fuzz_test.go
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
…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>
seidroid[bot]
seidroid Bot previously requested changes Jul 27, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md is 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 Apply boots with real file I/O, two full app.New constructions in TestGigaExecutorEnabledDrivesLastResultsHashValidation, and ~2,200 EventSinksFromConfig iterations in the sink tests — all under -race in go-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 a testutil/configtest/AGENTS.md so it isn't mistaken for one.
  • testutil/configtest is a new shared harness that the forthcoming differential work will consume, and the repo convention (top-level AGENTS.md) is a nested per-package guide for exactly this. The package doc comment in configtest.go covers a lot of it; a short AGENTS.md recording the invariants a new target must respect (call Isolate before touching env or $HOME, never t.Parallel, guard fuzzer-supplied paths with filepath.IsLocal, prefer failing over return on unexpected errors) would keep the next 100 targets consistent.
  • FuzzUpgradeVersionListOverride computes its expectation with strings.FieldsFunc(...) + semver.Sort(...), which is a line-for-line restatement of parseUpgradesList. It can detect a change but not a bug. TestUpgradeVersionListOverrideAcceptsAnyName carries 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.

Comment thread sei-db/common/utils/path_fuzz_test.go Outdated
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go Outdated
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go Outdated
Comment thread sei-wasmd/x/wasm/wasm_config_fuzz_test.go
Comment thread sei-tendermint/libs/cli/setup_config_fuzz_test.go
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread sei-db/common/utils/path_fuzz_test.go
bdchatham and others added 2 commits July 27, 2026 13:01
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>
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go Outdated
Comment thread sei-db/common/utils/path_fuzz_test.go
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>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.yml runs plain go test -race, which only replays f.Add seeds. No testdata/fuzz/** corpora are committed either, so the ongoing value of the 54 targets rests entirely on the seeds plus manual -fuzz sweeps. Consider a nightly/scheduled -fuzz job with a bounded -fuzztime for at least the Apply/section-reader targets.
  • CI runtime cost is worth measuring against the shard budget. Several targets do real work per seed: FuzzApplyPrecedenceTendermint/FuzzApplyPrecedenceApp/FuzzApplyIsIdempotent each run full Apply boots with file I/O (idempotence runs three per seed), rootseam/startprerun build a fresh NewRootCmd() per case, FuzzBaseAppConcurrencyWorkers constructs BaseApps, and TestGigaExecutorEnabledDrivesLastResultsHashValidation builds two full apps. Under -race plus coverage that is not free.
  • Dead harness API ships untested: fuzzing.TOMLScalar, configtest.DumpViperKeys, AppOpts.With/Without/Clone, Home.Read and CastIntSlice have 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 %g note on TOMLScalar).
  • configtest.Isolate documents that it does not save/restore server/config's package-global app.toml template or seilog's default level, and the cmd/seid/cmd targets do mutate the template via initAppConfig() + Apply on 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 of Isolate rather than relying on the doc comment.
  • AGENTS.md isn't updated to mention testutil/configtest as 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.

Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go
Comment thread sei-tendermint/config/config_fuzz_test.go Outdated
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go Outdated
Comment thread testutil/configtest/keyspec.go
Comment thread testutil/fuzzing/config.go Outdated
Comment thread cmd/seid/cmd/client_config_fuzz_test.go Outdated
@seidroid
seidroid Bot dismissed stale reviews from themself July 27, 2026 20:23

Superseded: latest AI review found no blocking issues.

Comment thread sei-tendermint/libs/cli/setup_config_fuzz_test.go
Comment thread sei-tendermint/config/config_fuzz_test.go Outdated
bdchatham and others added 2 commits July 27, 2026 13:49
…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>
Comment thread sei-db/common/utils/path_fuzz_test.go
Comment thread cmd/seid/cmd/legacy_config_fuzz_test.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) with f.Add(uint(16), ...) etc.). The // deny_list / // float into a float key comments on those seeds are correct today, but inserting a row anywhere in evmKeys/scKeys/ssKeys/ethReplayKeys silently 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-upgrades being unsettable in app.toml at all, the nondeterministic tx-index.indexer boot, and Apply never validating a pre-existing config.toml. That's the right call for a characterization suite, but these should be tracked as tickets rather than living solely as t.Fatalf prose, otherwise "pinned" and "accepted forever" become indistinguishable.
  • CI cost is non-trivial for a test-only diff: the sink file alone does ~2200 EventSinksFromConfig calls, and each cmd/seid/cmd fuzz seed builds a real root command and runs a full Apply with filesystem materialization (FuzzApplyIsIdempotent is 3 boots per seed). Worth checking the affected make test-group-* shard's wall clock under -race before merge.
  • In-package tests in sei-tendermint/config, sei-cosmos/baseapp, sei-cosmos/server/config and sei-db/config now import github.com/sei-protocol/sei-chain/testutil/.... That's fine inside the single root module today (and configtest deliberately imports no sei-chain package), but note testutil/fuzzing does pull in sei-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.

Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread sei-tendermint/types/genesis_config_fuzz_test.go Outdated
Comment thread sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go Outdated
…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>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md is 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.go runs 4×250 EventSinksFromConfig calls, TestGenesisDocWithoutTimeIsNotReproducible can run up to 1000 parse pairs, TestGigaExecutorEnabledDrivesLastResultsHashValidation constructs two full apps, and most cmd/seid/cmd targets do 1–3 full Apply boots per seed with real filesystem writes and full env clear/restore. Under -race with coverage this is a meaningful shard delta. Please report the measured before/after for the affected make test-group-N shards; if it's large, consider gating the highest-iteration loops behind testing.Short().
  • configtest.Isolate explicitly does not restore the two package-globals InterceptConfigsPreRunHandler writes (server/config.SetConfigTemplate and seilog.SetDefaultLevel). That is documented honestly, but this PR takes the number of tests in cmd/seid/cmd that 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/configtest is a non-test package that imports testing; that is a normal testutil pattern here, but it means anything that ever imports it links testing in. 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.

Comment thread sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go
Comment thread cmd/seid/cmd/legacy_config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
…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>
Comment thread app/consensus_config_fuzz_test.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md is empty), so this review merges only Claude's and Codex's findings. Codex contributed one P2 (the CheckKey single-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/configtest from sei-cosmos/, sei-tendermint/, sei-db/ and sei-wasmd/ (verified — no such imports existed before). Those trees are single-module today, so it compiles, but sei-tendermint/AGENTS.md treats 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 the AppOpts/Dump primitives inside each subtree.
  • CI budget: 54 fuzz targets replay their seed corpora on every go test -race run, and several tests do heavy repeated work — TestNullMixedWithAnUnsupportedSinkIsUnspecified, TestNullSinkAlwaysWinsOverARecognizedSink and TestNullSinkCanOpenAnIndexStoreItThenDiscards each loop 250–500 times, TestGenesisDocWithoutTimeIsNotReproducible may loop 1000 times, and TestGigaExecutorEnabledDrivesLastResultsHashValidation builds 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, the uint64 flag string passthrough, unsafe-skip-upgrades being unsettable in app.toml, the missing ValidateBasic on a pre-existing config.toml, nondeterministic tx-index sink selection, and InitEnv'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.

Comment thread testutil/configtest/keyspec.go Outdated
Comment thread testutil/configtest/keyspec.go
Comment thread cmd/seid/cmd/legacy_config_fuzz_test.go Outdated
Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go
Comment thread sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go
…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>
Comment thread sei-wasmd/x/wasm/wasm_config_fuzz_test.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread cmd/seid/cmd/rootseam_config_fuzz_test.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md is 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.go is not under sei-tendermint/, so the structured-concurrency / no-artificial-timeouts rules in sei-tendermint/AGENTS.md do not govern it (the root AGENTS.md has 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 requireSmallMapRandomization already 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/ compare err.Error() strings (TestEventSinksDistinguishesADuplicateFromAnUnsupportedName, TestValidateBasicDistinguishesAnAbsentModeFromAnUnknownOne), which sei-tendermint/AGENTS.md asks tests to avoid. The comments explain why (the production sites build bare errors.New/fmt.Errorf with 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.Add seed 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 (750 EventSinksFromConfig calls), up to 1000 retry pairs in TestGenesisDocWithoutTimeIsNotReproducible, and real app.New constructions in TestGigaExecutorEnabledDrivesLastResultsHashValidation. 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.md or README under testutil/configtest, and the root AGENTS.md isn'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.

Comment thread cmd/seid/cmd/startprerun_config_fuzz_test.go Outdated
Comment thread sei-tendermint/internal/state/indexer/sink/sink_fuzz_test.go
Comment thread testutil/configtest/env.go
…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>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.go that require Go's map-iteration randomization, and a 1000-attempt clock loop in sei-tendermint/types/genesis_config_fuzz_test.go. The requireSmallMapRandomization probe 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.Isolate documents that it does not restore two package globals the legacy read path mutates (config.SetConfigTemplate and seilog.SetDefaultLevel). The new cmd/seid/cmd targets do exercise both (every applyLegacy renders the template; the loglevel rows drive level resolution), so the cmd/seid/cmd binary 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.TOMLScalar has 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant