From b8e951362d0fe91d4ce07b4f436d2114f9d8a758 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 30 Jun 2026 16:29:15 -0700 Subject: [PATCH 01/15] =?UTF-8?q?feat(configmanager):=20SeiConfigManager?= =?UTF-8?q?=20v2=20body=20=E2=80=94=20validate-passthrough=20(PLT-775=20PR?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the v2 ConfigManager behind SEI_CONFIG_MANAGER=v2 (seam landed in #3671). v2 boot reads the config through the sei-config unified model to VALIDATE it, then re-enters the unchanged legacy reader on the operator's ORIGINAL files — it does not rewrite, migrate, or author sei.toml at boot. The two consumed channels are produced identically to legacy by construction; v2's boot value-add is the validation pass. - SeiConfigManager.Apply: resolveHomeDir (mirrors the legacy handler's flag>SEID_env>default resolution exactly) -> ReadConfigFromDir -> Validate -> re-enter. Advisory: read/validate problems are logged, never refuse boot. - Pin sei-config v0.0.21 (merged lenient-decode fix) so a real seid config.toml can be read for validation. - Legacy-vs-v2 differential test: both managers read the same home (v2 is passthrough); serverCtx.Config + Viper.AllSettings() must match, incl. after the start.go chain-id mutation. Design captured in bdchatham-designs designs/config-manager/DESIGN.md (validate-passthrough; advisory validation; sei.toml-as-source at the generate path; explicit migration). Fatal validation is the un-defer once sei-config read fidelity is hardened (a pruning read-mapping gap is surfaced advisory). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 100 ++++++++++++++---- .../cmd/configmanager/configmanager_test.go | 6 -- .../cmd/configmanager_differential_test.go | 81 ++++++++++++++ go.mod | 5 +- go.sum | 10 +- 5 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 cmd/seid/cmd/configmanager_differential_test.go diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index dd32f12d14..286a9d46c6 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -2,18 +2,26 @@ // loader and the (forthcoming) sei-config-backed manager, gated by the // SEI_CONFIG_MANAGER environment variable. // -// PR1 ships the seam only: LegacyConfigManager re-enters the unchanged -// legacy handler verbatim (the default), and SeiConfigManager is a -// not-yet-implemented stub that does not import the sei-config library. -// The v2 body lands in a follow-up PR. See PLT-775 and the canonical -// design (bdchatham-designs designs/config-manager/DESIGN.md). +// LegacyConfigManager re-enters the unchanged legacy handler verbatim (the +// default). SeiConfigManager re-authors the existing config through the +// sei-config library and then re-enters the same reader, so both channels are +// produced identically to legacy. See PLT-775 and the canonical design +// (bdchatham-designs designs/config-manager/DESIGN.md). package configmanager import ( + "errors" "fmt" + "os" + "path" + "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" + seiconfig "github.com/sei-protocol/sei-config" + + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" "github.com/sei-protocol/sei-chain/sei-cosmos/server" ) @@ -24,18 +32,17 @@ const EnvVar = "SEI_CONFIG_MANAGER" // // Load-bearing contract: an implementation must leave both channels the app // consumes — serverCtx.Config and serverCtx.Viper — populated exactly as the -// legacy path does, and must be all-or-nothing with respect to on-disk state -// (no partial config.toml/app.toml materialization on error). A v2 manager -// that authors the two files from the sei-config library must write BOTH -// atomically and then re-enter the legacy read tail so the channels are -// produced identically — it must not feed app.New from an in-memory struct. +// legacy path does. The boot path does not re-render the legacy files: v2 reads +// the config to validate it, then re-enters the unchanged legacy reader on the +// operator's existing files, so the channels are identical to legacy by +// construction. (Authoring the canonical sei.toml and rendering the legacy +// files from it is the generate path; any implementation that writes config +// must be all-or-nothing on disk.) // // The Apply signature matches server.InterceptConfigsPreRunHandler so the -// legacy implementation forwards verbatim. This is an internal, -// single-consumer contract (only root.go calls it) and is free to grow — an -// explicit home dir or a Prepare/Apply split — when the v2 write-then-re-enter -// body lands in a follow-up PR; the node dir is resolvable from cmd, so the -// signature is sufficient for PR1's seam. +// legacy implementation forwards verbatim. This is an internal, single-consumer +// contract (only root.go calls it) and is free to grow when the generate path +// lands; the node dir is resolvable from cmd. type ConfigManager interface { Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error } @@ -49,15 +56,64 @@ func (LegacyConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate str return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } -// SeiConfigManager will resolve configuration through the sei-config library -// behind SEI_CONFIG_MANAGER=v2. PR1 ships the seam only; the real body lands -// in a follow-up PR. It intentionally does not import sei-config. +// SeiConfigManager resolves configuration through the sei-config library, +// selected by SEI_CONFIG_MANAGER=v2. It reads the config through the unified +// SeiConfig model and surfaces validation diagnostics, then re-enters the +// legacy reader on the operator's original files — it does NOT rewrite them, +// migrate (that is the explicit `seid config migrate`), or author sei.toml (the +// generate path). So the produced config is identical to legacy by +// construction; v2's boot value-add is the validation pass. +// +// Validation is ADVISORY in this MVP: issues are logged, not enforced, and a +// read/validate problem never refuses boot. sei-config's read fidelity for a +// real seid config is still being hardened, so a model gap must not break an +// otherwise-valid node. Promoting validation to fatal (the design's +// refuse-on-error criterion) is the un-defer once the read round-trips real +// fixtures. type SeiConfigManager struct{} -// Apply is not yet implemented in PR1. It returns a hard error rather than -// silently falling back to the legacy path, so a v2 invocation is observable. -func (SeiConfigManager) Apply(_ *cobra.Command, _ string, _ any) error { - return fmt.Errorf("%s=v2 not yet implemented (PR1 seam only)", EnvVar) +// Apply surfaces sei-config validation diagnostics (advisory) and re-enters the +// legacy handler on the operator's original files. It does not write to disk, +// and never refuses boot on a read or validate problem. +func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { + if home, err := resolveHomeDir(cmd); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) + } else if cfg, err := seiconfig.ReadConfigFromDir(home); err != nil { + // A missing config file (fresh/partial home) is normal — the legacy + // handler creates it. Any other read error is advisory, not fatal. + if !errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) + } + } else if res := seiconfig.Validate(cfg); res.HasErrors() { + fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: validation found issues (advisory, not enforced): %v\n", res.Errors()) + } + + // Re-enter the unchanged legacy reader on the operator's original files. + return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) +} + +// resolveHomeDir mirrors the legacy handler's home resolution exactly +// (sei-cosmos/server/util.go: BindPFlags over the command's flags + the SEID_ +// env prefix + AutomaticEnv, then GetString(flags.FlagHome)), so the directory +// we materialize into is the same one the re-entered handler reads from. +// Resolving this single value the same way is not reimplementing the read tail +// — the read/merge/log-level tail stays delegated to InterceptConfigsPreRunHandler. +func resolveHomeDir(cmd *cobra.Command) (string, error) { + v := viper.New() + if err := v.BindPFlags(cmd.Flags()); err != nil { + return "", err + } + if err := v.BindPFlags(cmd.PersistentFlags()); err != nil { + return "", err + } + exe, err := os.Executable() + if err != nil { + return "", err + } + v.SetEnvPrefix(path.Base(exe)) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AutomaticEnv() + return v.GetString(flags.FlagHome), nil } // Select returns the ConfigManager chosen by the SEI_CONFIG_MANAGER value: diff --git a/cmd/seid/cmd/configmanager/configmanager_test.go b/cmd/seid/cmd/configmanager/configmanager_test.go index 7320346f67..e5ba992dba 100644 --- a/cmd/seid/cmd/configmanager/configmanager_test.go +++ b/cmd/seid/cmd/configmanager/configmanager_test.go @@ -33,9 +33,3 @@ func TestSelect(t *testing.T) { }) } } - -// TestSeiConfigManagerNotImplemented asserts the v2 stub fails hard rather -// than silently behaving like legacy (PR1 ships the seam only). -func TestSeiConfigManagerNotImplemented(t *testing.T) { - require.Error(t, (SeiConfigManager{}).Apply(nil, "", nil)) -} diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go new file mode 100644 index 0000000000..686ee1760f --- /dev/null +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/sdk/trace" + + "github.com/sei-protocol/sei-chain/cmd/seid/cmd/configmanager" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" +) + +// errStopPreRun aborts the command after the config-resolution PreRunE, before +// StartCmd's RunE tries to boot a node. +var errStopPreRun = errors.New("stop after prerun") + +// runConfigManager runs mgr.Apply inside a StartCmd's PreRunE against homeDir, +// using seid's real app-config template, and returns the populated server +// context (the two channels start.go/app.New consume). It mirrors the harness +// in sei-cosmos/server/util_test.go. +func runConfigManager(t *testing.T, mgr configmanager.ConfigManager, homeDir string) *server.Context { + t.Helper() + template, appCfg := initAppConfig() + + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + require.NoError(t, cmd.Flags().Set(flags.FlagHome, homeDir)) + cmd.PreRunE = func(c *cobra.Command, _ []string) error { + if err := mgr.Apply(c, template, appCfg); err != nil { + return err + } + return errStopPreRun + } + + serverCtx := &server.Context{} + ctx := context.WithValue(context.Background(), server.ServerContextKey, serverCtx) + require.ErrorIs(t, cmd.ExecuteContext(ctx), errStopPreRun) + return serverCtx +} + +// TestConfigManagerLegacyVsV2Differential is the core safety property: the v2 +// manager must produce the SAME consumed config as the legacy path. v2 reads +// the config (to validate it) and then re-enters the legacy reader on the +// operator's ORIGINAL files — it does not rewrite — so the two paths read the +// SAME home and any difference is a real divergence, not a path artifact. +// +// It compares parsed semantics: +// - serverCtx.Config (the *tmcfg.Config the node runs on), and +// - serverCtx.Viper.AllSettings() (the AppOptions every Sei section reads via +// appOpts.Get), both at end-of-PersistentPreRunE and after the start.go +// chain-id mutation. +// +// A realistic fixture (all 11 Sei sections) is generated by letting the legacy +// handler create the full default config once; both managers then read it. +func TestConfigManagerLegacyVsV2Differential(t *testing.T) { + home := t.TempDir() + + // Generate a complete, realistic config via the legacy creator (fresh home). + _ = runConfigManager(t, configmanager.LegacyConfigManager{}, home) + + // Both managers read the same populated home. v2 is passthrough (no rewrite), + // so RootDir and every other path-derived field match by construction. + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2") + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2") + + // The start.go chain-id mutation is identical on both vipers; assert parity + // holds after it too (covers the post-mutation snapshot). + const chainID = "differential-test-1" + legacyCtx.Viper.Set(flags.FlagChainID, chainID) + v2Ctx.Viper.Set(flags.FlagChainID, chainID) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "settings diverge after the start.go chain-id mutation") +} diff --git a/go.mod b/go.mod index 1b94a8aef9..45413d7a33 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.6 require ( cosmossdk.io/errors v1.0.2 github.com/99designs/keyring v1.2.1 - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c + github.com/BurntSushi/toml v1.5.0 github.com/adlio/schema v1.3.9 github.com/alitto/pond v1.8.3 github.com/armon/go-metrics v0.4.1 @@ -71,6 +71,7 @@ require ( github.com/sasha-s/go-deadlock v0.3.5 github.com/segmentio/kafka-go v0.4.50 github.com/sei-protocol/goutils v0.0.2 + github.com/sei-protocol/sei-config v0.0.21 github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 github.com/sei-protocol/sei-tm-db v0.0.5 github.com/sei-protocol/seilog v0.0.3 @@ -187,7 +188,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect diff --git a/go.sum b/go.sum index f9a82a3eff..9ace91cdd0 100644 --- a/go.sum +++ b/go.sum @@ -619,8 +619,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDm github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= @@ -1083,8 +1083,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= @@ -1808,6 +1808,8 @@ github.com/sei-protocol/go-ethereum v1.15.7-sei-17 h1:jUDHZqcKAiLi8nR0isZPZn8nDZ github.com/sei-protocol/go-ethereum v1.15.7-sei-17/go.mod h1:+S9k+jFzlyVTNcYGvqFhzN/SFhI6vA+aOY4T5tLSPL0= github.com/sei-protocol/goutils v0.0.2 h1:Bfa7Sv+4CVLNM20QcpvGb81B8C5HkQC/kW1CQpIbXDA= github.com/sei-protocol/goutils v0.0.2/go.mod h1:iYE2DuJfEnM+APPehr2gOUXfuLuPsVxorcDO+Tzq9q8= +github.com/sei-protocol/sei-config v0.0.21 h1:0VXOz91v9YeM2dmpKi1XaIFy+p3EamDAJtnj02XLKIw= +github.com/sei-protocol/sei-config v0.0.21/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 h1:f2sY8OcN60UL1/6POx+HDMZ4w04FTZtSScnrFSnGZHg= github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082/go.mod h1:V0fNURAjS6A8+sA1VllegjNeSobay3oRUW5VFZd04bA= github.com/sei-protocol/sei-tm-db v0.0.5 h1:3WONKdSXEqdZZeLuWYfK5hP37TJpfaUa13vAyAlvaQY= From b5a71b734555888acfca9d211c27063ab688e5ff Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 30 Jun 2026 16:40:18 -0700 Subject: [PATCH 02/15] review(configmanager): xreview fixes (doc-drift, louder error advisory, home test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2 xreview slate (all RATIFY) follow-ups: - doc: package comment said v2 "re-authors" — stale from the old write-then- re-enter model; v2 reads to validate, does not rewrite (idiomatic-reviewer + seidroid, convergent). - advisory: distinguish validation ERRORS with a louder lead-in — they are the same SeverityError class (sc-write-mode) legacy panics on later at app.New(); surfaced earlier, not enforced (systems-engineer dissent F2). - doc: note validation runs against the on-disk files only, not the merged AppOptions, so a default node's pruning advisory is expected/benign — the template omits the key cosmos defaults in code (sei-network-specialist). - test: TestResolveHomeDir_Flag covers the --home resolution. Deferred (dissent F4, sanctioned cut): env-precedence + mixed-home rows ride the generate-path PR — the boot differential already proves passthrough parity by construction. Design MVP-scope reconciled separately (bdchatham-designs). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 26 ++++++++++++++----- .../cmd/configmanager/configmanager_test.go | 19 ++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index 286a9d46c6..d387ce7f57 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -3,9 +3,10 @@ // SEI_CONFIG_MANAGER environment variable. // // LegacyConfigManager re-enters the unchanged legacy handler verbatim (the -// default). SeiConfigManager re-authors the existing config through the -// sei-config library and then re-enters the same reader, so both channels are -// produced identically to legacy. See PLT-775 and the canonical design +// default). SeiConfigManager reads the existing config through the sei-config +// library to *validate* it, then re-enters the same reader on the operator's +// original files (it does not rewrite them), so both channels are produced +// identically to legacy. See PLT-775 and the canonical design // (bdchatham-designs designs/config-manager/DESIGN.md). package configmanager @@ -75,17 +76,30 @@ type SeiConfigManager struct{} // Apply surfaces sei-config validation diagnostics (advisory) and re-enters the // legacy handler on the operator's original files. It does not write to disk, // and never refuses boot on a read or validate problem. +// +// Validation runs against the on-disk config.toml/app.toml only (via +// ReadConfigFromDir) — NOT the fully-merged AppOptions the node boots on (no +// flag/env/in-code-default layering). So a default node logs a benign advisory +// today (e.g. `storage.pruning` empty, because the template omits the key that +// cosmos defaults in code); this is expected, not a regression. That lower +// fidelity is acceptable precisely because validation is advisory here — parity +// comes from re-entry, not from the validated struct. func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { if home, err := resolveHomeDir(cmd); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) } else if cfg, err := seiconfig.ReadConfigFromDir(home); err != nil { - // A missing config file (fresh/partial home) is normal — the legacy - // handler creates it. Any other read error is advisory, not fatal. + // A missing config.toml/app.toml (fresh home, or a partial home with one + // file absent) is normal — the legacy handler creates it. Any other read + // error is advisory, not fatal. if !errors.Is(err, os.ErrNotExist) { fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) } } else if res := seiconfig.Validate(cfg); res.HasErrors() { - fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: validation found issues (advisory, not enforced): %v\n", res.Errors()) + // Advisory in this MVP: the node still boots. These are SeverityError + // findings — the same class (e.g. sc-write-mode) that legacy panics on + // later at app.New(); surfacing them here is earlier warning, not + // enforcement. Fatal refuse-on-error is the un-defer. + fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: ADVISORY config validation errors (not enforced; the node will boot, but these may surface later, e.g. at app.New()): %v\n", res.Errors()) } // Re-enter the unchanged legacy reader on the operator's original files. diff --git a/cmd/seid/cmd/configmanager/configmanager_test.go b/cmd/seid/cmd/configmanager/configmanager_test.go index e5ba992dba..9b2ccebc56 100644 --- a/cmd/seid/cmd/configmanager/configmanager_test.go +++ b/cmd/seid/cmd/configmanager/configmanager_test.go @@ -3,7 +3,10 @@ package configmanager import ( "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" ) // TestSelect covers the dispatch table: unset and "legacy" select the @@ -33,3 +36,19 @@ func TestSelect(t *testing.T) { }) } } + +// TestResolveHomeDir_Flag confirms resolveHomeDir reads the --home flag — the +// value v2 validates against must be the dir the re-entered handler reads. (Env +// precedence follows viper, mirrored from the legacy handler; the env case is +// awkward to exercise here because the prefix is the test binary's basename, +// not SEID_, so it rides the generate-path PR — the boot differential already +// proves passthrough parity regardless of where home resolves.) +func TestResolveHomeDir_Flag(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String(flags.FlagHome, "", "") + require.NoError(t, cmd.Flags().Set(flags.FlagHome, "/tmp/seid-test-home")) + + got, err := resolveHomeDir(cmd) + require.NoError(t, err) + require.Equal(t, "/tmp/seid-test-home", got) +} From 13b3a44e8c7c190d1ab61c803a052058fcfb0513 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 09:42:14 -0700 Subject: [PATCH 03/15] fix(configmanager): lint errcheck + surface validation warnings (agentic findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses agentic reviewer findings on PR #3678: - lint (errcheck, red CI): the three advisory fmt.Fprintf(cmd.ErrOrStderr()) calls didn't check the return -> `_, _ =` (golangci-lint clean locally). - claude[bot] (correctness): the advisory branch fired only on HasErrors() and printed only Errors(), silently dropping SeverityWarning diagnostics. Now logs the full res.Diagnostics (each carries its [ERROR]/[WARNING]/[INFO] severity) so warnings are surfaced too; errors stay distinguished. - claude[bot] (doc): drop the stale "(forthcoming)" from the package comment — this PR lands the manager. Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index d387ce7f57..746922aee0 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -1,6 +1,6 @@ // Package configmanager is the selection seam between the legacy config -// loader and the (forthcoming) sei-config-backed manager, gated by the -// SEI_CONFIG_MANAGER environment variable. +// loader and the sei-config-backed manager, gated by the SEI_CONFIG_MANAGER +// environment variable. // // LegacyConfigManager re-enters the unchanged legacy handler verbatim (the // default). SeiConfigManager reads the existing config through the sei-config @@ -86,20 +86,22 @@ type SeiConfigManager struct{} // comes from re-entry, not from the validated struct. func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { if home, err := resolveHomeDir(cmd); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) } else if cfg, err := seiconfig.ReadConfigFromDir(home); err != nil { // A missing config.toml/app.toml (fresh home, or a partial home with one // file absent) is normal — the legacy handler creates it. Any other read // error is advisory, not fatal. if !errors.Is(err, os.ErrNotExist) { - fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) } - } else if res := seiconfig.Validate(cfg); res.HasErrors() { - // Advisory in this MVP: the node still boots. These are SeverityError - // findings — the same class (e.g. sc-write-mode) that legacy panics on - // later at app.New(); surfacing them here is earlier warning, not - // enforcement. Fatal refuse-on-error is the un-defer. - fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: ADVISORY config validation errors (not enforced; the node will boot, but these may surface later, e.g. at app.New()): %v\n", res.Errors()) + } else if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { + // Advisory in this MVP: the node still boots. Surface ALL diagnostics — + // each carries its own [ERROR]/[WARNING]/[INFO] severity — so warnings + // are not silently dropped alongside errors. SeverityError findings (e.g. + // sc-write-mode) are the class legacy panics on later at app.New(); + // surfacing them here is earlier warning, not enforcement. Fatal + // refuse-on-error is the un-defer. + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: ADVISORY config validation diagnostics (not enforced; the node will boot, but SeverityError items may surface later, e.g. at app.New()): %v\n", diags) } // Re-enter the unchanged legacy reader on the operator's original files. From 9756ba4a31b5e6f574ff622dfbff14cdbf27e613 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 10:30:46 -0700 Subject: [PATCH 04/15] test(configmanager): cover env-precedence home resolution (DWC-1) The flag-driven differential never exercised resolveHomeDir's SetEnvPrefix/AutomaticEnv/replacer mirror of the legacy handler. Add an env-driven twin harness + TestConfigManagerLegacyVsV2Differential_EnvHome: drive home through the environment (no --home) and assert legacy and v2 resolve the SAME home, with a non-vacuous guard that the env var actually drove resolution. Closes the silent-drift seam the advisory design cannot otherwise surface (systems-engineer review). Co-Authored-By: Claude Opus 4.8 --- .../cmd/configmanager_differential_test.go | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index 686ee1760f..7814c2a1a1 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -3,6 +3,9 @@ package cmd import ( "context" "errors" + "os" + "path" + "strings" "testing" "github.com/spf13/cobra" @@ -18,16 +21,45 @@ import ( // StartCmd's RunE tries to boot a node. var errStopPreRun = errors.New("stop after prerun") -// runConfigManager runs mgr.Apply inside a StartCmd's PreRunE against homeDir, -// using seid's real app-config template, and returns the populated server -// context (the two channels start.go/app.New consume). It mirrors the harness -// in sei-cosmos/server/util_test.go. +// runConfigManager runs mgr.Apply inside a StartCmd's PreRunE against homeDir +// supplied via the --home flag, using seid's real app-config template, and +// returns the populated server context (the two channels start.go/app.New +// consume). It mirrors the harness in sei-cosmos/server/util_test.go. func runConfigManager(t *testing.T, mgr configmanager.ConfigManager, homeDir string) *server.Context { t.Helper() - template, appCfg := initAppConfig() - cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) require.NoError(t, cmd.Flags().Set(flags.FlagHome, homeDir)) + return execConfigManager(t, mgr, cmd) +} + +// runConfigManagerEnvHome is runConfigManager's twin that supplies homeDir +// through the environment instead of --home, exercising the +// SetEnvPrefix/AutomaticEnv/replacer machinery that resolveHomeDir mirrors from +// the legacy handler (the flag-driven path never touches it). The env prefix is +// path.Base(os.Executable()) — the test binary — derived identically by BOTH +// the legacy handler (sei-cosmos/server/util.go:152,162-164) and v2's +// resolveHomeDir, so both resolve the same home. viper's lookup key for "home" +// is ToUpper(prefix + "_" + "home") with the ".","-" -> "_" replacer applied. +func runConfigManagerEnvHome(t *testing.T, mgr configmanager.ConfigManager, homeDir string) *server.Context { + t.Helper() + exe, err := os.Executable() + require.NoError(t, err) + envKey := strings.NewReplacer(".", "_", "-", "_").Replace( + strings.ToUpper(path.Base(exe) + "_" + flags.FlagHome)) + t.Setenv(envKey, homeDir) + + // Leave --home unset: an unchanged flag default ranks below AutomaticEnv in + // viper's precedence, so the env value is what resolves. + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + return execConfigManager(t, mgr, cmd) +} + +// execConfigManager runs mgr.Apply inside cmd's PreRunE (aborting before the +// node boots) and returns the populated server context. The caller configures +// how home is supplied (flag vs env) on cmd beforehand. +func execConfigManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) *server.Context { + t.Helper() + template, appCfg := initAppConfig() cmd.PreRunE = func(c *cobra.Command, _ []string) error { if err := mgr.Apply(c, template, appCfg); err != nil { return err @@ -79,3 +111,36 @@ func TestConfigManagerLegacyVsV2Differential(t *testing.T) { require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "settings diverge after the start.go chain-id mutation") } + +// TestConfigManagerLegacyVsV2Differential_EnvHome exercises the env-precedence +// half of resolveHomeDir's mirror of the legacy handler — the flag-driven +// differential above never touches SetEnvPrefix/AutomaticEnv. When home is +// supplied via the environment (not --home), v2 must resolve the SAME home the +// legacy handler does; otherwise v2 would advisorily validate one dir while the +// re-entered legacy reader boots on another — a silent drift the advisory +// design cannot surface (no error, no diagnostic). This pins the seam so a +// future change to the legacy env-resolution can't diverge undetected. +func TestConfigManagerLegacyVsV2Differential_EnvHome(t *testing.T) { + home := t.TempDir() + + // Populate a complete realistic config in `home` via the fresh-home legacy + // creator, driven entirely through the env var (no --home). + _ = runConfigManagerEnvHome(t, configmanager.LegacyConfigManager{}, home) + + legacyCtx := runConfigManagerEnvHome(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManagerEnvHome(t, configmanager.SeiConfigManager{}, home) + + // Non-vacuous guard: the env var actually drove resolution. If the key were + // wrong, both would fall back to StartCmd's "/foobar" default (and the + // legacy creator would fail writing under it) — this asserts the env path + // resolved to the temp home, for both managers. + require.Equal(t, home, v2Ctx.Viper.GetString(flags.FlagHome), + "env-provided home did not drive v2 resolution") + require.Equal(t, home, legacyCtx.Viper.GetString(flags.FlagHome), + "env-provided home did not drive legacy resolution") + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2 on the env-home path") + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2 on the env-home path") +} From 393578d07225bd6863d771ae5d475168c1d2450f Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 10:52:23 -0700 Subject: [PATCH 05/15] docs(configmanager): fix stale env-case comment on TestResolveHomeDir_Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DWC-1 commit (9756ba4) added TestConfigManagerLegacyVsV2Differential_EnvHome covering the env-driven home resolution, but left the TestResolveHomeDir_Flag comment claiming the env case 'rides the generate-path PR' — now self- contradictory. Point it at the env-home differential test instead (claude[bot]). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager_test.go b/cmd/seid/cmd/configmanager/configmanager_test.go index 9b2ccebc56..336e1879a0 100644 --- a/cmd/seid/cmd/configmanager/configmanager_test.go +++ b/cmd/seid/cmd/configmanager/configmanager_test.go @@ -39,10 +39,10 @@ func TestSelect(t *testing.T) { // TestResolveHomeDir_Flag confirms resolveHomeDir reads the --home flag — the // value v2 validates against must be the dir the re-entered handler reads. (Env -// precedence follows viper, mirrored from the legacy handler; the env case is -// awkward to exercise here because the prefix is the test binary's basename, -// not SEID_, so it rides the generate-path PR — the boot differential already -// proves passthrough parity regardless of where home resolves.) +// precedence follows viper, mirrored from the legacy handler; the end-to-end +// env-driven case is exercised by TestConfigManagerLegacyVsV2Differential_EnvHome +// in the cmd package, which resolves the test-binary-basename prefix and asserts +// legacy/v2 parity.) func TestResolveHomeDir_Flag(t *testing.T) { cmd := &cobra.Command{} cmd.Flags().String(flags.FlagHome, "", "") From 120a6832a6ddc8f73013fdbb1828561b34a5ae2c Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 13:13:59 -0700 Subject: [PATCH 06/15] docs(configmanager): move narrative to doc.go, trim to human-audience form The in-file documentation was overbearing. Move the package narrative into a dedicated doc.go (single home for the why), cut each exported symbol's go-doc to its point, and drop the inline block comments in Apply so the code reads on its own. doc.go leads with the safety invariant (never rewrites, never refuses boot) per the /lingua R2 pass (prose-steward). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 98 ++++----------------- cmd/seid/cmd/configmanager/doc.go | 19 ++++ 2 files changed, 38 insertions(+), 79 deletions(-) create mode 100644 cmd/seid/cmd/configmanager/doc.go diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index 746922aee0..72129a544d 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -1,13 +1,3 @@ -// Package configmanager is the selection seam between the legacy config -// loader and the sei-config-backed manager, gated by the SEI_CONFIG_MANAGER -// environment variable. -// -// LegacyConfigManager re-enters the unchanged legacy handler verbatim (the -// default). SeiConfigManager reads the existing config through the sei-config -// library to *validate* it, then re-enters the same reader on the operator's -// original files (it does not rewrite them), so both channels are produced -// identically to legacy. See PLT-775 and the canonical design -// (bdchatham-designs designs/config-manager/DESIGN.md). package configmanager import ( @@ -26,94 +16,48 @@ import ( "github.com/sei-protocol/sei-chain/sei-cosmos/server" ) -// EnvVar is the experimental gate that selects the configuration manager. +// EnvVar gates which configuration manager seid uses. const EnvVar = "SEI_CONFIG_MANAGER" // ConfigManager resolves a seid node's configuration during PersistentPreRunE. -// -// Load-bearing contract: an implementation must leave both channels the app -// consumes — serverCtx.Config and serverCtx.Viper — populated exactly as the -// legacy path does. The boot path does not re-render the legacy files: v2 reads -// the config to validate it, then re-enters the unchanged legacy reader on the -// operator's existing files, so the channels are identical to legacy by -// construction. (Authoring the canonical sei.toml and rendering the legacy -// files from it is the generate path; any implementation that writes config -// must be all-or-nothing on disk.) -// -// The Apply signature matches server.InterceptConfigsPreRunHandler so the -// legacy implementation forwards verbatim. This is an internal, single-consumer -// contract (only root.go calls it) and is free to grow when the generate path -// lands; the node dir is resolvable from cmd. +// An implementation must leave serverCtx.Config and serverCtx.Viper populated +// exactly as the legacy path does. The Apply signature matches +// server.InterceptConfigsPreRunHandler so the legacy manager forwards verbatim. type ConfigManager interface { Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error } -// LegacyConfigManager is the default manager: it re-enters the unchanged -// legacy handler verbatim, so the legacy path stays byte-for-byte unaffected. +// LegacyConfigManager is the default manager. It forwards to the legacy handler +// unchanged, leaving the legacy path byte-for-byte unaffected. type LegacyConfigManager struct{} -// Apply delegates to the legacy interception handler unchanged. +// Apply forwards to the legacy interception handler unchanged. func (LegacyConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } -// SeiConfigManager resolves configuration through the sei-config library, -// selected by SEI_CONFIG_MANAGER=v2. It reads the config through the unified -// SeiConfig model and surfaces validation diagnostics, then re-enters the -// legacy reader on the operator's original files — it does NOT rewrite them, -// migrate (that is the explicit `seid config migrate`), or author sei.toml (the -// generate path). So the produced config is identical to legacy by -// construction; v2's boot value-add is the validation pass. -// -// Validation is ADVISORY in this MVP: issues are logged, not enforced, and a -// read/validate problem never refuses boot. sei-config's read fidelity for a -// real seid config is still being hardened, so a model gap must not break an -// otherwise-valid node. Promoting validation to fatal (the design's -// refuse-on-error criterion) is the un-defer once the read round-trips real -// fixtures. +// SeiConfigManager validates the config through the sei-config library, then +// re-enters the legacy handler on the operator's original files. It never +// writes, migrates, or refuses boot. type SeiConfigManager struct{} -// Apply surfaces sei-config validation diagnostics (advisory) and re-enters the -// legacy handler on the operator's original files. It does not write to disk, -// and never refuses boot on a read or validate problem. -// -// Validation runs against the on-disk config.toml/app.toml only (via -// ReadConfigFromDir) — NOT the fully-merged AppOptions the node boots on (no -// flag/env/in-code-default layering). So a default node logs a benign advisory -// today (e.g. `storage.pruning` empty, because the template omits the key that -// cosmos defaults in code); this is expected, not a regression. That lower -// fidelity is acceptable precisely because validation is advisory here — parity -// comes from re-entry, not from the validated struct. +// Apply prints advisory validation diagnostics, then re-enters the legacy +// handler. A missing config file is not an error, and nothing here refuses boot. func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { if home, err := resolveHomeDir(cmd); err != nil { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) } else if cfg, err := seiconfig.ReadConfigFromDir(home); err != nil { - // A missing config.toml/app.toml (fresh home, or a partial home with one - // file absent) is normal — the legacy handler creates it. Any other read - // error is advisory, not fatal. if !errors.Is(err, os.ErrNotExist) { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) } } else if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { - // Advisory in this MVP: the node still boots. Surface ALL diagnostics — - // each carries its own [ERROR]/[WARNING]/[INFO] severity — so warnings - // are not silently dropped alongside errors. SeverityError findings (e.g. - // sc-write-mode) are the class legacy panics on later at app.New(); - // surfacing them here is earlier warning, not enforcement. Fatal - // refuse-on-error is the un-defer. - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: ADVISORY config validation diagnostics (not enforced; the node will boot, but SeverityError items may surface later, e.g. at app.New()): %v\n", diags) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: advisory validation diagnostics (not enforced; node will boot): %v\n", diags) } - - // Re-enter the unchanged legacy reader on the operator's original files. return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } -// resolveHomeDir mirrors the legacy handler's home resolution exactly -// (sei-cosmos/server/util.go: BindPFlags over the command's flags + the SEID_ -// env prefix + AutomaticEnv, then GetString(flags.FlagHome)), so the directory -// we materialize into is the same one the re-entered handler reads from. -// Resolving this single value the same way is not reimplementing the read tail -// — the read/merge/log-level tail stays delegated to InterceptConfigsPreRunHandler. +// resolveHomeDir resolves --home the same way the legacy handler does +// (sei-cosmos/server/util.go), so v2 validates the directory the handler reads. func resolveHomeDir(cmd *cobra.Command) (string, error) { v := viper.New() if err := v.BindPFlags(cmd.Flags()); err != nil { @@ -132,14 +76,10 @@ func resolveHomeDir(cmd *cobra.Command) (string, error) { return v.GetString(flags.FlagHome), nil } -// Select returns the ConfigManager chosen by the SEI_CONFIG_MANAGER value: -// unset or "legacy" -> LegacyConfigManager (the default); "v2" -> -// SeiConfigManager; any other value -> error (never a silent fallback). -// getenv is injected for testability; production callers pass os.Getenv. -// -// The value is matched exactly — no trimming or case-folding. This is -// deliberate: normalizing would broaden the gate, and the error names the -// legal tokens so an operator can self-correct. +// Select maps SEI_CONFIG_MANAGER to a manager: unset or "legacy" -> Legacy, +// "v2" -> Sei, anything else -> error. The value is matched exactly (no +// trimming or case-folding) and never falls back silently. getenv is injected +// for tests; callers pass os.Getenv. func Select(getenv func(string) string) (ConfigManager, error) { switch v := getenv(EnvVar); v { case "", "legacy": diff --git a/cmd/seid/cmd/configmanager/doc.go b/cmd/seid/cmd/configmanager/doc.go new file mode 100644 index 0000000000..fe3a5d1281 --- /dev/null +++ b/cmd/seid/cmd/configmanager/doc.go @@ -0,0 +1,19 @@ +// Package configmanager selects how seid loads its configuration, behind the +// SEI_CONFIG_MANAGER gate. Every manager boots the node identically; the only +// variable is an advisory validation pass that never rewrites a file and never +// refuses boot. +// +// SEI_CONFIG_MANAGER picks the manager: unset or "legacy" uses the legacy +// loader unchanged; "v2" uses the sei-config-backed manager. root.go calls +// Select once, during PersistentPreRunE. +// +// Both managers boot from the same two channels — serverCtx.Config and +// serverCtx.Viper — and v2 populates them exactly as legacy does: it re-enters +// the legacy reader on the operator's own files instead of rewriting them. Its +// validation pass is advisory because sei-config's read fidelity is still being +// hardened, and a gap in the model must not fail a valid node. +// +// Two things are deferred: making validation fatal, and authoring a canonical +// sei.toml to render the legacy files from (the generate path). See PLT-775 and +// the design (bdchatham-designs designs/config-manager/DESIGN.md). +package configmanager From d0bf870b3d8198bd3beab54407b739f90b9b68c2 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 13:38:18 -0700 Subject: [PATCH 07/15] =?UTF-8?q?test(configmanager):=20rich=20parity=20su?= =?UTF-8?q?rface=20=E2=80=94=20corpus,=20advisory-invariance,=20fuzz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the legacy-vs-v2 proof beyond the single default fixture, all unit-tier: - Corpus differential (table-driven): default, leading comments/blanks, unknown section, quoted scalar (#36 lenient-decode) — parity holds for any on-disk shape, including ones that exercise sei-config's own reader. - Advisory-invariance: v2 never refuses boot on a valid config, and on an unreadable config it returns the SAME error legacy does (not masked, not invented) after logging the advisory read failure. - FuzzConfigManagerLegacyVsV2Parity: arbitrary app.toml suffix -> legacy and v2 reach the same outcome (identical channels, or the identical error). Runs the seed corpus deterministically in CI; open to -fuzz locally. Harness: runManager now captures stderr + the Apply error so the advisory paths are observable; execConfigManager keeps the happy-path assertion. Co-Authored-By: Claude Opus 4.8 --- .../cmd/configmanager_differential_test.go | 184 +++++++++++++++++- 1 file changed, 176 insertions(+), 8 deletions(-) diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index 7814c2a1a1..bdc3912155 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -1,10 +1,12 @@ package cmd import ( + "bytes" "context" "errors" "os" "path" + "path/filepath" "strings" "testing" @@ -26,10 +28,16 @@ var errStopPreRun = errors.New("stop after prerun") // returns the populated server context (the two channels start.go/app.New // consume). It mirrors the harness in sei-cosmos/server/util_test.go. func runConfigManager(t *testing.T, mgr configmanager.ConfigManager, homeDir string) *server.Context { + t.Helper() + return execConfigManager(t, mgr, startCmdForHome(t, homeDir)) +} + +// startCmdForHome builds a StartCmd with --home set to homeDir. +func startCmdForHome(t *testing.T, homeDir string) *cobra.Command { t.Helper() cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) require.NoError(t, cmd.Flags().Set(flags.FlagHome, homeDir)) - return execConfigManager(t, mgr, cmd) + return cmd } // runConfigManagerEnvHome is runConfigManager's twin that supplies homeDir @@ -54,23 +62,75 @@ func runConfigManagerEnvHome(t *testing.T, mgr configmanager.ConfigManager, home return execConfigManager(t, mgr, cmd) } -// execConfigManager runs mgr.Apply inside cmd's PreRunE (aborting before the -// node boots) and returns the populated server context. The caller configures -// how home is supplied (flag vs env) on cmd beforehand. +// execConfigManager runs mgr.Apply on the happy path (Apply succeeds; boot is +// aborted with errStopPreRun) and returns the populated server context. The +// caller configures how home is supplied (flag vs env) on cmd beforehand. func execConfigManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) *server.Context { + t.Helper() + ctx, _, err := runManager(t, mgr, cmd) + require.NoError(t, err) + return ctx +} + +// runManager runs mgr.Apply inside cmd's PreRunE, capturing what an operator +// would see and do: the populated server context, whatever Apply wrote to +// stderr (advisory diagnostics), and the error Apply returned. Apply is the +// only boot-refusing call, so on the happy path it returns nil and boot is +// aborted with errStopPreRun; on a real config error it returns that error and +// runManager surfaces it unchanged. The caller sets home on cmd beforehand. +func runManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) (*server.Context, string, error) { t.Helper() template, appCfg := initAppConfig() + + var stderr bytes.Buffer + cmd.SetErr(&stderr) + + var applyErr error cmd.PreRunE = func(c *cobra.Command, _ []string) error { - if err := mgr.Apply(c, template, appCfg); err != nil { - return err + if applyErr = mgr.Apply(c, template, appCfg); applyErr != nil { + return applyErr } return errStopPreRun } serverCtx := &server.Context{} ctx := context.WithValue(context.Background(), server.ServerContextKey, serverCtx) - require.ErrorIs(t, cmd.ExecuteContext(ctx), errStopPreRun) - return serverCtx + execErr := cmd.ExecuteContext(ctx) + if applyErr == nil { + require.ErrorIs(t, execErr, errStopPreRun) + } + return serverCtx, stderr.String(), applyErr +} + +// appTOMLPath and cfgTOMLPath are the two files the legacy creator writes into a +// home and both managers then read. +func appTOMLPath(home string) string { return filepath.Join(home, "config", "app.toml") } +func cfgTOMLPath(home string) string { return filepath.Join(home, "config", "config.toml") } + +// seedDefaultConfig generates a complete, realistic config (all Sei sections) by +// letting the legacy creator write into a fresh home, and returns that home. +func seedDefaultConfig(t *testing.T) string { + t.Helper() + home := t.TempDir() + _ = runConfigManager(t, configmanager.LegacyConfigManager{}, home) + return home +} + +// appendToFile appends s to the file at path (both managers must still agree on +// the result). +func appendToFile(t *testing.T, path, s string) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(b, []byte(s)...), 0o600)) +} + +// prependToFile prepends s to the file at path. +func prependToFile(t *testing.T, path, s string) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append([]byte(s), b...), 0o600)) } // TestConfigManagerLegacyVsV2Differential is the core safety property: the v2 @@ -144,3 +204,111 @@ func TestConfigManagerLegacyVsV2Differential_EnvHome(t *testing.T) { require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "serverCtx.Viper settings differ between legacy and v2 on the env-home path") } + +// TestConfigManagerLegacyVsV2Differential_Corpus widens the parity proof from +// the single default fixture to a corpus of realistic on-disk shapes. Parity is +// by construction (v2 re-enters the legacy reader), so any shape an operator +// could present must produce identical channels — including shapes that exercise +// sei-config's own reader (quoted scalars, unknown keys), whose advisory read +// still must not perturb what the node boots on. +func TestConfigManagerLegacyVsV2Differential_Corpus(t *testing.T) { + cases := []struct { + name string + mutate func(t *testing.T, home string) + }{ + {"default", func(t *testing.T, home string) {}}, + {"leading-comments-and-blanks", func(t *testing.T, home string) { + prependToFile(t, appTOMLPath(home), "# corpus: a leading comment\n\n") + }}, + {"unknown-section", func(t *testing.T, home string) { + appendToFile(t, appTOMLPath(home), "\n[sei-corpus-unknown]\nkey = \"value\"\n") + }}, + {"quoted-scalar", func(t *testing.T, home string) { + // A number written as a quoted string — the sei-config lenient-decode + // case (#36). v2 reads it; the channels must still match legacy. + appendToFile(t, appTOMLPath(home), "\n[sei-corpus-scalar]\ncount = \"100000\"\n") + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := seedDefaultConfig(t) + tc.mutate(t, home) + + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2 (%s)", tc.name) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2 (%s)", tc.name) + }) + } +} + +// TestConfigManagerV2AdvisoryNeverRefusesBoot pins the advisory invariant: on a +// valid config, v2 boots exactly as legacy does (Apply returns nil, both +// channels match), regardless of any diagnostics it prints. v2 adds +// observability, never a new boot outcome. +func TestConfigManagerV2AdvisoryNeverRefusesBoot(t *testing.T) { + home := seedDefaultConfig(t) + + v2Ctx, _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + require.NoError(t, v2Err, "advisory validation must never refuse boot on a valid config") + + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + require.Equal(t, legacyCtx.Config, v2Ctx.Config) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings()) +} + +// TestConfigManagerV2AdvisoryReadErrorMatchesLegacy pins the other half of the +// invariant: when the config is unreadable, v2 must not mask the failure or +// invent a new one. It logs an advisory read error, then re-enters the legacy +// handler and returns exactly the error legacy returns. +func TestConfigManagerV2AdvisoryReadErrorMatchesLegacy(t *testing.T) { + home := seedDefaultConfig(t) + require.NoError(t, os.WriteFile(cfgTOMLPath(home), []byte("this is ] not [ valid toml"), 0o600)) + + _, _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + _, v2Stderr, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + + require.Error(t, legacyErr, "corrupt config.toml should fail the legacy reader") + require.Equal(t, legacyErr.Error(), v2Err.Error(), + "v2 must return the same boot error as legacy, not mask or add one") + require.Contains(t, v2Stderr, "could not read config for validation", + "v2 should log the advisory read failure before re-entering legacy") +} + +// FuzzConfigManagerLegacyVsV2Parity is the exhaustive form of the corpus: for an +// arbitrary suffix appended to a valid app.toml, legacy and v2 must reach the +// same outcome — identical channels when both succeed, the identical error when +// both fail. Parity is by construction, so the fuzzer should never find a +// divergence. Under `go test` (no -fuzz) it runs the seed corpus, which is a +// deterministic differential in CI. +func FuzzConfigManagerLegacyVsV2Parity(f *testing.F) { + for _, seed := range []string{ + "", + "\n# a trailing comment\n", + "\n[fuzz-extra]\nk = \"v\"\n", + "\n[fuzz-scalar]\nn = \"100000\"\n", + "\nnot valid toml ][", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, appTOMLSuffix string) { + home := seedDefaultConfig(t) + appendToFile(t, appTOMLPath(home), appTOMLSuffix) + + legacyCtx, _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + v2Ctx, _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + + if (legacyErr == nil) != (v2Err == nil) { + t.Fatalf("divergent outcome for suffix %q: legacyErr=%v v2Err=%v", appTOMLSuffix, legacyErr, v2Err) + } + if legacyErr != nil { + require.Equal(t, legacyErr.Error(), v2Err.Error(), "divergent error for suffix %q", appTOMLSuffix) + return + } + require.Equal(t, legacyCtx.Config, v2Ctx.Config, "Config diverges for suffix %q", appTOMLSuffix) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "settings diverge for suffix %q", appTOMLSuffix) + }) +} From 35842d92beffc879d231c8b5d639a2a635f783d7 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 13:57:53 -0700 Subject: [PATCH 08/15] refactor(configmanager): named-step Apply, shared corpus, reduction doc Adopt the systems-engineer's extensible pattern for the workstream: - Apply is now two named steps (validateAdvisory -> re-enter legacy), so the generate path can add its authoring/render step as a sibling instead of rewriting Apply. Advisory pass is a void helper; boot outcome is unchanged. - Shared configCorpus() is the single source of interesting on-disk shapes, consumed by BOTH the table-driven differential and the fuzz target (fuzz now crosses every shape with an arbitrary suffix). Kills the corpus/seed drift. - Add the cosmos-only-write-mode case: the deprecated state-commit.sc-write-mode version-skew class. sei-config still accepts it, so v2 raises no diagnostic today; the case proves read-parity now and becomes a caught case once fatal validation + a sei-config deprecation rule land. - doc.go states the reduction lemma: the node boots on the two channels, not the model, so channel-equality is the whole correctness argument. Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 28 ++++- cmd/seid/cmd/configmanager/doc.go | 4 + .../cmd/configmanager_differential_test.go | 108 ++++++++++++------ 3 files changed, 97 insertions(+), 43 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index 72129a544d..0f77030b59 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -41,19 +41,35 @@ func (LegacyConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate str // writes, migrates, or refuses boot. type SeiConfigManager struct{} -// Apply prints advisory validation diagnostics, then re-enters the legacy -// handler. A missing config file is not an error, and nothing here refuses boot. +// Apply runs the advisory validation pass, then re-enters the legacy handler on +// the operator's original files. Nothing in the validation pass refuses boot. func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { - if home, err := resolveHomeDir(cmd); err != nil { + validateAdvisory(cmd) + return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) +} + +// validateAdvisory resolves the home dir, reads the on-disk config, and prints +// any validation diagnostics to stderr. Every step is advisory: a failure is +// logged and swallowed so the pass can never change what the node boots on. A +// missing config file is normal (the legacy handler creates it) and is not +// surfaced. Keeping this a distinct step from Apply is what lets the generate +// path add its authoring/render step as a sibling. +func validateAdvisory(cmd *cobra.Command) { + home, err := resolveHomeDir(cmd) + if err != nil { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) - } else if cfg, err := seiconfig.ReadConfigFromDir(home); err != nil { + return + } + cfg, err := seiconfig.ReadConfigFromDir(home) + if err != nil { if !errors.Is(err, os.ErrNotExist) { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) } - } else if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { + return + } + if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: advisory validation diagnostics (not enforced; node will boot): %v\n", diags) } - return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } // resolveHomeDir resolves --home the same way the legacy handler does diff --git a/cmd/seid/cmd/configmanager/doc.go b/cmd/seid/cmd/configmanager/doc.go index fe3a5d1281..3db2733fe2 100644 --- a/cmd/seid/cmd/configmanager/doc.go +++ b/cmd/seid/cmd/configmanager/doc.go @@ -13,6 +13,10 @@ // validation pass is advisory because sei-config's read fidelity is still being // hardened, and a gap in the model must not fail a valid node. // +// The node boots on those two channels, never on the SeiConfig model; that is +// why differential tests suffice — proving v2's channels equal legacy's, which +// legacy is already trusted to boot on, is the whole correctness argument. +// // Two things are deferred: making validation fatal, and authoring a canonical // sei.toml to render the legacy files from (the generate path). See PLT-775 and // the design (bdchatham-designs designs/config-manager/DESIGN.md). diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index bdc3912155..5bb8b0f87f 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -133,6 +133,52 @@ func prependToFile(t *testing.T, path, s string) { require.NoError(t, os.WriteFile(path, append([]byte(s), b...), 0o600)) } +// replaceInFile replaces old with new in the file at path, asserting old was +// present so a corpus mutation can never silently become a no-op. +func replaceInFile(t *testing.T, path, old, new string) { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(b), old, "replace target %q not found — fixture would be vacuous", old) + require.NoError(t, os.WriteFile(path, []byte(strings.ReplaceAll(string(b), old, new)), 0o600)) +} + +// corpusCase is one realistic on-disk config shape, applied to a freshly-seeded +// default home. It is the shared unit both the table-driven differential and the +// fuzz target consume, so the set of "interesting shapes" lives in one place. +type corpusCase struct { + name string + mutate func(t *testing.T, home string) +} + +// configCorpus is the single source of the config shapes the parity proof runs +// over. Each case mutates a default home in place; parity must hold for all of +// them because v2 re-enters the legacy reader regardless of what it read. +func configCorpus() []corpusCase { + return []corpusCase{ + {"default", func(t *testing.T, home string) {}}, + {"leading-comments-and-blanks", func(t *testing.T, home string) { + prependToFile(t, appTOMLPath(home), "# corpus: a leading comment\n\n") + }}, + {"unknown-section", func(t *testing.T, home string) { + appendToFile(t, appTOMLPath(home), "\n[sei-corpus-unknown]\nkey = \"value\"\n") + }}, + {"quoted-scalar", func(t *testing.T, home string) { + // A number written as a quoted string — the sei-config lenient-decode + // case (#36). v2 reads it; the channels must still match legacy. + appendToFile(t, appTOMLPath(home), "\n[sei-corpus-scalar]\ncount = \"100000\"\n") + }}, + {"cosmos-only-write-mode", func(t *testing.T, home string) { + // The version-skew class: a config carrying the deprecated + // state-commit.sc-write-mode "cosmos_only". sei-config still accepts it + // as valid, so v2 raises no diagnostic today; the point here is that + // both managers read it identically (parity). It becomes a *caught* + // case only once fatal validation + a sei-config deprecation rule land. + replaceInFile(t, appTOMLPath(home), `sc-write-mode = "memiavl_only"`, `sc-write-mode = "cosmos_only"`) + }}, + } +} + // TestConfigManagerLegacyVsV2Differential is the core safety property: the v2 // manager must produce the SAME consumed config as the legacy path. v2 reads // the config (to validate it) and then re-enters the legacy reader on the @@ -212,24 +258,7 @@ func TestConfigManagerLegacyVsV2Differential_EnvHome(t *testing.T) { // sei-config's own reader (quoted scalars, unknown keys), whose advisory read // still must not perturb what the node boots on. func TestConfigManagerLegacyVsV2Differential_Corpus(t *testing.T) { - cases := []struct { - name string - mutate func(t *testing.T, home string) - }{ - {"default", func(t *testing.T, home string) {}}, - {"leading-comments-and-blanks", func(t *testing.T, home string) { - prependToFile(t, appTOMLPath(home), "# corpus: a leading comment\n\n") - }}, - {"unknown-section", func(t *testing.T, home string) { - appendToFile(t, appTOMLPath(home), "\n[sei-corpus-unknown]\nkey = \"value\"\n") - }}, - {"quoted-scalar", func(t *testing.T, home string) { - // A number written as a quoted string — the sei-config lenient-decode - // case (#36). v2 reads it; the channels must still match legacy. - appendToFile(t, appTOMLPath(home), "\n[sei-corpus-scalar]\ncount = \"100000\"\n") - }}, - } - for _, tc := range cases { + for _, tc := range configCorpus() { t.Run(tc.name, func(t *testing.T) { home := seedDefaultConfig(t) tc.mutate(t, home) @@ -278,37 +307,42 @@ func TestConfigManagerV2AdvisoryReadErrorMatchesLegacy(t *testing.T) { "v2 should log the advisory read failure before re-entering legacy") } -// FuzzConfigManagerLegacyVsV2Parity is the exhaustive form of the corpus: for an -// arbitrary suffix appended to a valid app.toml, legacy and v2 must reach the -// same outcome — identical channels when both succeed, the identical error when -// both fail. Parity is by construction, so the fuzzer should never find a -// divergence. Under `go test` (no -fuzz) it runs the seed corpus, which is a -// deterministic differential in CI. +// FuzzConfigManagerLegacyVsV2Parity is the exhaustive form of the corpus: it +// crosses every corpus shape with an arbitrary appended app.toml suffix, and +// asserts legacy and v2 reach the same outcome — identical channels when both +// succeed, the identical error when both fail. Parity is by construction, so the +// fuzzer should never find a divergence. Under `go test` (no -fuzz) it runs the +// seed corpus (each shape × a few suffixes), a deterministic differential in CI; +// under -fuzz it explores suffixes against every shape. func FuzzConfigManagerLegacyVsV2Parity(f *testing.F) { - for _, seed := range []string{ - "", - "\n# a trailing comment\n", - "\n[fuzz-extra]\nk = \"v\"\n", - "\n[fuzz-scalar]\nn = \"100000\"\n", - "\nnot valid toml ][", - } { - f.Add(seed) + corpus := configCorpus() + for i := range corpus { + f.Add(i, "") + f.Add(i, "\n# a trailing comment\n") + f.Add(i, "\nnot valid toml ][") } - f.Fuzz(func(t *testing.T, appTOMLSuffix string) { + + f.Fuzz(func(t *testing.T, corpusIdx int, appTOMLSuffix string) { + if corpusIdx < 0 { + corpusIdx = -corpusIdx + } + tc := corpus[corpusIdx%len(corpus)] + home := seedDefaultConfig(t) + tc.mutate(t, home) appendToFile(t, appTOMLPath(home), appTOMLSuffix) legacyCtx, _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) v2Ctx, _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) if (legacyErr == nil) != (v2Err == nil) { - t.Fatalf("divergent outcome for suffix %q: legacyErr=%v v2Err=%v", appTOMLSuffix, legacyErr, v2Err) + t.Fatalf("divergent outcome (case %q, suffix %q): legacyErr=%v v2Err=%v", tc.name, appTOMLSuffix, legacyErr, v2Err) } if legacyErr != nil { - require.Equal(t, legacyErr.Error(), v2Err.Error(), "divergent error for suffix %q", appTOMLSuffix) + require.Equal(t, legacyErr.Error(), v2Err.Error(), "divergent error (case %q, suffix %q)", tc.name, appTOMLSuffix) return } - require.Equal(t, legacyCtx.Config, v2Ctx.Config, "Config diverges for suffix %q", appTOMLSuffix) - require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "settings diverge for suffix %q", appTOMLSuffix) + require.Equal(t, legacyCtx.Config, v2Ctx.Config, "Config diverges (case %q, suffix %q)", tc.name, appTOMLSuffix) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "settings diverge (case %q, suffix %q)", tc.name, appTOMLSuffix) }) } From bf69ff030ca75922436b3ab8a13e81f5cf49f20f Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 14:04:16 -0700 Subject: [PATCH 09/15] test(configmanager): fuzz corpusIdx as uint; de-shadow replaceInFile idiomatic-reviewer nits on the fold-in: - Fuzz index is now uint, dropping the sign guard and the math.MinInt negation edge (negating MinInt stays negative -> corpus[neg] panic; a fuzzer would hit it). Validated: 15s / 4354 execs, no panic, no divergence. - replaceInFile params renamed oldStr/newStr to stop shadowing builtin new. Co-Authored-By: Claude Opus 4.8 --- .../cmd/configmanager_differential_test.go | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index 5bb8b0f87f..1126d0bb05 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -133,14 +133,14 @@ func prependToFile(t *testing.T, path, s string) { require.NoError(t, os.WriteFile(path, append([]byte(s), b...), 0o600)) } -// replaceInFile replaces old with new in the file at path, asserting old was -// present so a corpus mutation can never silently become a no-op. -func replaceInFile(t *testing.T, path, old, new string) { +// replaceInFile replaces oldStr with newStr in the file at path, asserting +// oldStr was present so a corpus mutation can never silently become a no-op. +func replaceInFile(t *testing.T, path, oldStr, newStr string) { t.Helper() b, err := os.ReadFile(path) require.NoError(t, err) - require.Contains(t, string(b), old, "replace target %q not found — fixture would be vacuous", old) - require.NoError(t, os.WriteFile(path, []byte(strings.ReplaceAll(string(b), old, new)), 0o600)) + require.Contains(t, string(b), oldStr, "replace target %q not found — fixture would be vacuous", oldStr) + require.NoError(t, os.WriteFile(path, []byte(strings.ReplaceAll(string(b), oldStr, newStr)), 0o600)) } // corpusCase is one realistic on-disk config shape, applied to a freshly-seeded @@ -317,16 +317,15 @@ func TestConfigManagerV2AdvisoryReadErrorMatchesLegacy(t *testing.T) { func FuzzConfigManagerLegacyVsV2Parity(f *testing.F) { corpus := configCorpus() for i := range corpus { - f.Add(i, "") - f.Add(i, "\n# a trailing comment\n") - f.Add(i, "\nnot valid toml ][") + f.Add(uint(i), "") + f.Add(uint(i), "\n# a trailing comment\n") + f.Add(uint(i), "\nnot valid toml ][") } - f.Fuzz(func(t *testing.T, corpusIdx int, appTOMLSuffix string) { - if corpusIdx < 0 { - corpusIdx = -corpusIdx - } - tc := corpus[corpusIdx%len(corpus)] + // corpusIdx is unsigned so a fuzzed index maps to a case with a plain modulo + // — no sign guard, no math.MinInt negation edge. + f.Fuzz(func(t *testing.T, corpusIdx uint, appTOMLSuffix string) { + tc := corpus[corpusIdx%uint(len(corpus))] home := seedDefaultConfig(t) tc.mutate(t, home) From 3606e933662dd78e5fdd12e14f97c64b36464e63 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 14:50:38 -0700 Subject: [PATCH 10/15] refactor(configmanager): log advisory diagnostics via seilog, not fmt.Fprintf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advisory validation diagnostics now go through seilog (the seid-wide logger, already used in root.go) at Warn, instead of raw fmt.Fprintf to cmd stderr. seilog is a package-global slog logger available in PersistentPreRunE — it does not depend on the node/server-context logger (wired later inside the re-entered handler) — so logging is now structured, level-controlled, and consistent with the rest of seid. Tests: the advisory paths' behavior (I2 — v2 returns the same error as legacy, boots identically) is unchanged and still asserted. seilog fixes its output sink at package init (no per-test hook), so the log-TEXT assertion is dropped — the invariant, not the log text, is what the test protects. runManager simplified to (ctx, err). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 16 +++++--- .../cmd/configmanager_differential_test.go | 41 +++++++++---------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index 0f77030b59..d6bc8104bb 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -11,11 +11,14 @@ import ( "github.com/spf13/viper" seiconfig "github.com/sei-protocol/sei-config" + "github.com/sei-protocol/seilog" "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" "github.com/sei-protocol/sei-chain/sei-cosmos/server" ) +var logger = seilog.NewLogger("cmd", "seid", "configmanager") + // EnvVar gates which configuration manager seid uses. const EnvVar = "SEI_CONFIG_MANAGER" @@ -48,27 +51,28 @@ func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } -// validateAdvisory resolves the home dir, reads the on-disk config, and prints -// any validation diagnostics to stderr. Every step is advisory: a failure is -// logged and swallowed so the pass can never change what the node boots on. A +// validateAdvisory resolves the home dir, reads the on-disk config, and logs any +// validation diagnostics via seilog at Warn. Every step is advisory: a failure +// is logged and swallowed so the pass can never change what the node boots on. A // missing config file is normal (the legacy handler creates it) and is not // surfaced. Keeping this a distinct step from Apply is what lets the generate // path add its authoring/render step as a sibling. func validateAdvisory(cmd *cobra.Command) { home, err := resolveHomeDir(cmd) if err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not resolve home dir for validation (advisory): %v\n", err) + logger.Warn("could not resolve home dir for config validation (advisory)", "err", err) return } cfg, err := seiconfig.ReadConfigFromDir(home) if err != nil { if !errors.Is(err, os.ErrNotExist) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: could not read config for validation (advisory): %v\n", err) + logger.Warn("could not read config for validation (advisory)", "err", err) } return } if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "config-manager v2: advisory validation diagnostics (not enforced; node will boot): %v\n", diags) + logger.Warn("advisory config validation diagnostics (not enforced; node will boot)", + "count", len(diags), "diagnostics", fmt.Sprintf("%v", diags)) } } diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index 1126d0bb05..29c9cce3d2 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -1,9 +1,9 @@ package cmd import ( - "bytes" "context" "errors" + "io" "os" "path" "path/filepath" @@ -67,23 +67,22 @@ func runConfigManagerEnvHome(t *testing.T, mgr configmanager.ConfigManager, home // caller configures how home is supplied (flag vs env) on cmd beforehand. func execConfigManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) *server.Context { t.Helper() - ctx, _, err := runManager(t, mgr, cmd) + ctx, err := runManager(t, mgr, cmd) require.NoError(t, err) return ctx } -// runManager runs mgr.Apply inside cmd's PreRunE, capturing what an operator -// would see and do: the populated server context, whatever Apply wrote to -// stderr (advisory diagnostics), and the error Apply returned. Apply is the -// only boot-refusing call, so on the happy path it returns nil and boot is -// aborted with errStopPreRun; on a real config error it returns that error and -// runManager surfaces it unchanged. The caller sets home on cmd beforehand. -func runManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) (*server.Context, string, error) { +// runManager runs mgr.Apply inside cmd's PreRunE and returns the populated +// server context and the error Apply returned. Apply is the only boot-refusing +// call, so on the happy path it returns nil and boot is aborted with +// errStopPreRun; on a real config error it returns that error and runManager +// surfaces it unchanged. Advisory diagnostics go to seilog (not cmd's stderr), +// so they are not captured here — the invariants under test are the returned +// context and error, not the log text. The caller sets home on cmd beforehand. +func runManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) (*server.Context, error) { t.Helper() template, appCfg := initAppConfig() - - var stderr bytes.Buffer - cmd.SetErr(&stderr) + cmd.SetErr(io.Discard) // swallow cobra's own error echo; advisory logs go to seilog var applyErr error cmd.PreRunE = func(c *cobra.Command, _ []string) error { @@ -99,7 +98,7 @@ func runManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Comman if applyErr == nil { require.ErrorIs(t, execErr, errStopPreRun) } - return serverCtx, stderr.String(), applyErr + return serverCtx, applyErr } // appTOMLPath and cfgTOMLPath are the two files the legacy creator writes into a @@ -281,7 +280,7 @@ func TestConfigManagerLegacyVsV2Differential_Corpus(t *testing.T) { func TestConfigManagerV2AdvisoryNeverRefusesBoot(t *testing.T) { home := seedDefaultConfig(t) - v2Ctx, _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) require.NoError(t, v2Err, "advisory validation must never refuse boot on a valid config") legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) @@ -291,20 +290,18 @@ func TestConfigManagerV2AdvisoryNeverRefusesBoot(t *testing.T) { // TestConfigManagerV2AdvisoryReadErrorMatchesLegacy pins the other half of the // invariant: when the config is unreadable, v2 must not mask the failure or -// invent a new one. It logs an advisory read error, then re-enters the legacy -// handler and returns exactly the error legacy returns. +// invent a new one. It logs an advisory read error (via seilog), then re-enters +// the legacy handler and returns exactly the error legacy returns. func TestConfigManagerV2AdvisoryReadErrorMatchesLegacy(t *testing.T) { home := seedDefaultConfig(t) require.NoError(t, os.WriteFile(cfgTOMLPath(home), []byte("this is ] not [ valid toml"), 0o600)) - _, _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) - _, v2Stderr, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) require.Error(t, legacyErr, "corrupt config.toml should fail the legacy reader") require.Equal(t, legacyErr.Error(), v2Err.Error(), "v2 must return the same boot error as legacy, not mask or add one") - require.Contains(t, v2Stderr, "could not read config for validation", - "v2 should log the advisory read failure before re-entering legacy") } // FuzzConfigManagerLegacyVsV2Parity is the exhaustive form of the corpus: it @@ -331,8 +328,8 @@ func FuzzConfigManagerLegacyVsV2Parity(f *testing.F) { tc.mutate(t, home) appendToFile(t, appTOMLPath(home), appTOMLSuffix) - legacyCtx, _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) - v2Ctx, _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + legacyCtx, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) if (legacyErr == nil) != (v2Err == nil) { t.Fatalf("divergent outcome (case %q, suffix %q): legacyErr=%v v2Err=%v", tc.name, appTOMLSuffix, legacyErr, v2Err) From 8e3384bbd6a0f59d131b6e129c43bea563ecd98d Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 14:55:01 -0700 Subject: [PATCH 11/15] refactor(configmanager): keep advisory diagnostics structured in the log idiomatic-reviewer follow-ups on the seilog switch: - Log diagnostics as a []string of Diagnostic.String() instead of fmt.Sprintf("%v", diags): structured/queryable under the JSON handler and still readable under text, where a bare []Diagnostic would struct-dump. - Use the "error" attribute key to match the sibling logger calls (ethreplay.go). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index d6bc8104bb..deca3d7e36 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -60,19 +60,23 @@ func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string func validateAdvisory(cmd *cobra.Command) { home, err := resolveHomeDir(cmd) if err != nil { - logger.Warn("could not resolve home dir for config validation (advisory)", "err", err) + logger.Warn("could not resolve home dir for config validation (advisory)", "error", err) return } cfg, err := seiconfig.ReadConfigFromDir(home) if err != nil { if !errors.Is(err, os.ErrNotExist) { - logger.Warn("could not read config for validation (advisory)", "err", err) + logger.Warn("could not read config for validation (advisory)", "error", err) } return } if diags := seiconfig.Validate(cfg).Diagnostics; len(diags) > 0 { + msgs := make([]string, len(diags)) + for i, d := range diags { + msgs[i] = d.String() + } logger.Warn("advisory config validation diagnostics (not enforced; node will boot)", - "count", len(diags), "diagnostics", fmt.Sprintf("%v", diags)) + "count", len(diags), "diagnostics", msgs) } } From 19d724db41472eb721955214bec5caf572982a39 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 15:04:20 -0700 Subject: [PATCH 12/15] fix(configmanager): recover panics in the advisory validation pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisory pass's contract is 'never refuses boot,' but a panic in seiconfig.ReadConfigFromDir/Validate (read fidelity still being hardened) would propagate out of Apply and crash boot — violating advisory-invariance (I2). Guard validateAdvisory with a recover that logs the panic and lets boot proceed via the re-entered legacy reader, so the pass truly cannot change the boot outcome (seidroid). Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index deca3d7e36..b5118d2318 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -52,12 +52,19 @@ func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string } // validateAdvisory resolves the home dir, reads the on-disk config, and logs any -// validation diagnostics via seilog at Warn. Every step is advisory: a failure -// is logged and swallowed so the pass can never change what the node boots on. A -// missing config file is normal (the legacy handler creates it) and is not -// surfaced. Keeping this a distinct step from Apply is what lets the generate -// path add its authoring/render step as a sibling. +// validation diagnostics via seilog at Warn. Every step is advisory: a failure — +// or a panic in the sei-config read/validate, whose fidelity is still being +// hardened — is logged and swallowed so the pass can never change what the node +// boots on. A missing config file is normal (the legacy handler creates it) and +// is not surfaced. Keeping this a distinct step from Apply is what lets the +// generate path add its authoring/render step as a sibling. func validateAdvisory(cmd *cobra.Command) { + defer func() { + if r := recover(); r != nil { + logger.Warn("config validation panicked (advisory; recovered, node will boot)", "panic", r) + } + }() + home, err := resolveHomeDir(cmd) if err != nil { logger.Warn("could not resolve home dir for config validation (advisory)", "error", err) From 16e32f48087c4b99d2538530c113a96c8fe37a7c Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 15:16:46 -0700 Subject: [PATCH 13/15] fix(configmanager): log recovered validation panic at Error, not Warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recovered panic is an unexpected defect (a crash in sei-config), not a benign advisory diagnostic — Error surfaces it even when SEI_LOG_LEVEL filters Warn. The message still states the node boots. (idiomatic-reviewer) Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index b5118d2318..df40eba675 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -61,7 +61,7 @@ func (SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string func validateAdvisory(cmd *cobra.Command) { defer func() { if r := recover(); r != nil { - logger.Warn("config validation panicked (advisory; recovered, node will boot)", "panic", r) + logger.Error("config validation panicked (advisory; recovered, node will boot)", "panic", r) } }() From 0c2b78ef7b38439a6dc158858c33c6b5b3619ed7 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 15:28:16 -0700 Subject: [PATCH 14/15] test(configmanager): cover the fresh-home ErrNotExist advisory branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seidroid nit: validateAdvisory's silent-skip depends on sei-config wrapping a missing config as an os.ErrNotExist-satisfying error; if that regressed, every fresh-home boot would emit a spurious advisory warning. Verified io.go:26-27 wraps toml.DecodeFile (os.Open -> PathError) with %w, and pin it: - TestReadConfigFromDirMissingIsErrNotExist: asserts ReadConfigFromDir on an empty dir returns an errors.Is(os.ErrNotExist) error — fails here, not noisily in prod, if sei-config swaps to a custom not-found error. - TestConfigManagerV2FreshHomeBoots: v2 on a truly fresh home (no config yet) must boot — the only cover of the ErrNotExist branch, the common new-node case. Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager/configmanager_test.go | 13 +++++++++++++ cmd/seid/cmd/configmanager_differential_test.go | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/cmd/seid/cmd/configmanager/configmanager_test.go b/cmd/seid/cmd/configmanager/configmanager_test.go index 336e1879a0..16f191a054 100644 --- a/cmd/seid/cmd/configmanager/configmanager_test.go +++ b/cmd/seid/cmd/configmanager/configmanager_test.go @@ -1,11 +1,14 @@ package configmanager import ( + "os" "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/require" + seiconfig "github.com/sei-protocol/sei-config" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" ) @@ -52,3 +55,13 @@ func TestResolveHomeDir_Flag(t *testing.T) { require.NoError(t, err) require.Equal(t, "/tmp/seid-test-home", got) } + +// TestReadConfigFromDirMissingIsErrNotExist pins the contract validateAdvisory's +// silent-skip depends on: a missing config file must yield an error that +// errors.Is(os.ErrNotExist) recognizes, so a fresh-home boot skips the advisory +// read quietly instead of logging a spurious warning. If sei-config ever swaps +// to a custom not-found error, this fails here rather than going noisy in prod. +func TestReadConfigFromDirMissingIsErrNotExist(t *testing.T) { + _, err := seiconfig.ReadConfigFromDir(t.TempDir()) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index 29c9cce3d2..8d93e5e48a 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -288,6 +288,18 @@ func TestConfigManagerV2AdvisoryNeverRefusesBoot(t *testing.T) { require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings()) } +// TestConfigManagerV2FreshHomeBoots exercises the fresh-home first-boot path: v2's +// advisory read hits os.ErrNotExist (no config yet), silently skips, then +// re-enters the legacy handler, which creates the files. It must not refuse boot. +// Every other test pre-seeds the config, so this is the only cover of the +// ErrNotExist branch — the common case for a brand-new node. +func TestConfigManagerV2FreshHomeBoots(t *testing.T) { + home := t.TempDir() + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + require.NoError(t, v2Err, "v2 on a fresh home must not refuse boot on the missing-config read") + require.NotNil(t, v2Ctx.Config) +} + // TestConfigManagerV2AdvisoryReadErrorMatchesLegacy pins the other half of the // invariant: when the config is unreadable, v2 must not mask the failure or // invent a new one. It logs an advisory read error (via seilog), then re-enters From 15781a24dd16ef45a0f81e4a91af2078d3771ca8 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 1 Jul 2026 15:55:20 -0700 Subject: [PATCH 15/15] test(configmanager): make quoted-scalar corpus case real, fix overclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude[bot] nit: the quoted-scalar case appended an unknown [sei-corpus-scalar] section, which sei-config drops (no ErrorUnused) — behaviorally identical to the unknown-section case, and its comment overclaimed that it exercises #36's lenient decode. Retarget to a real numeric field written quoted (ss-keep-recent = "100000"), so it's a genuinely distinct shape, and correct the comment: the differential only observes channel parity (independent of v2's advisory read), so coercion correctness is verified in sei-config's own #36 tests, not here. Co-Authored-By: Claude Opus 4.8 --- cmd/seid/cmd/configmanager_differential_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go index 8d93e5e48a..6777a55ceb 100644 --- a/cmd/seid/cmd/configmanager_differential_test.go +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -163,9 +163,13 @@ func configCorpus() []corpusCase { appendToFile(t, appTOMLPath(home), "\n[sei-corpus-unknown]\nkey = \"value\"\n") }}, {"quoted-scalar", func(t *testing.T, home string) { - // A number written as a quoted string — the sei-config lenient-decode - // case (#36). v2 reads it; the channels must still match legacy. - appendToFile(t, appTOMLPath(home), "\n[sei-corpus-scalar]\ncount = \"100000\"\n") + // A real numeric field written as a quoted string (sei-config #36's + // lenient-decode shape). Both paths re-enter the same legacy reader, so + // the channels match regardless — parity holds because v2's read is + // advisory. Coercion of quoted primitives is verified in sei-config's + // own tests; the differential only observes channel parity, not the + // advisory read's outcome. + replaceInFile(t, appTOMLPath(home), "ss-keep-recent = 100000", `ss-keep-recent = "100000"`) }}, {"cosmos-only-write-mode", func(t *testing.T, home string) { // The version-skew class: a config carrying the deprecated