Skip to content

[ConfigManager] Read sei.toml at Boot - #3999

Draft
bdchatham wants to merge 7 commits into
plt-775-node-sections-4from
plt-775-node-install
Draft

[ConfigManager] Read sei.toml at Boot#3999
bdchatham wants to merge 7 commits into
plt-775-node-sections-4from
plt-775-node-install

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

What this does

A node that selects this configuration manager reads sei.toml and runs what it says. Until
now the file was declared and nothing consumed it.

Two deliveries, because the two halves of a node's configuration are read differently and only
one of them can be delivered by putting a value into the source the boot builds.

Keys a reader looks up one at a time are installed into that source. These are read when
the app asks, which is after this runs.

Keys a reader decodes whole are decoded into the struct instead. The boot's handler reads
the node's own configuration file once into tmcfg.Config before this runs, and the node reads
that struct, so a value put into the source afterwards reaches nothing. Measured before
building it: with the value installed, the source read tcp://0.0.0.0:19999 while the node
still ran tcp://0.0.0.0:26656.

A section declares which of the two it takes, and the sections of the node's own file declare
it in the same loop that registers them, so one cannot be registered without being delivered. A
section that disagrees fails a test rather than quietly changing nothing.

The properties this rests on

Only what a source supplied is delivered. A resolution answers for every declared key, and
a declared value is what a provisioning command writes for a kind of node rather than what any
node runs. Delivering one would replace a setting an operator never mentioned, on every boot,
for every key their file omits. This is the cut that makes the whole thing safe to enable, and
it now has a test of its own: both deliveries narrow independently, in three functions, so the
rule was a convention three call sites remembered. TestNoDeliveryCarriesADeclaredDefault
boots with a file supplying one key and asserts nothing else moved, in either delivery, for
every kind of node.

A section at a time. A decode is all or nothing for whatever it is handed, so one refused
value would otherwise cost every key in the file rather than the keys of the section it
appeared in. An operator who fixes one setting and mistypes another has to end up with the
first applied.

Decoded into a copy, published by replacing it. A decoder gathers errors and keeps going,
so a refused value partway leaves its target holding some new values and some old, with
nothing to compare against. The copy is of the configuration the node already has and not a
fresh one, because what a decoder writes can depend on what its target already holds. It
shares nothing: a decoder writes a list into the array its target holds, so one shared list
would edit the original during the rehearsal meant to protect it.

Nothing here can refuse a boot. Selecting this manager is a switch, not a configuration
change. No file, an unreadable one, a mode this binary does not declare defaults for: each
installs nothing and every key reads as it always has. A mistyped line in a hand-editable file
must not become an outage on the next restart.

The flag snapshot is taken at the entry to Apply. The handler copies configuration values
into flags and marks them changed, so afterwards a flag an operator typed and a key their
app.toml holds are indistinguishable, and a flag layer built from that state would put
app.toml above sei.toml. There is no later point where the truth survives.

Values the decoder accepts and turns into something else

Both decode cleanly, which is why nothing downstream objects.

A length of time written as a plain number is read as nanoseconds, so sixty means sixty
billionths of a second and the node starts. Refused, with the unit to write. Zero is allowed:
nanoseconds and seconds are the same at zero, several of these settings document zero as the
way to turn them off, and three declare it.

A negative number where the field cannot hold one wraps to the largest value that field
has. Minus one is how an operator says "no limit" in most software they have used, and it would
turn the ceiling on connected peers into no ceiling and a window in seconds into six centuries.
Refused for the eight declared keys on unsigned fields.

Keys this does not declare, and who writes them instead

The criterion is not that this binary fills a value in. It is that something does, and the
file cannot see it.

The cluster's node controller resolves a peer set from live discovery and patches the addresses
in. A node computes a trust height and hash from the chain tip each time it starts. A moniker
is stamped per instance, and its default is the host name of whatever machine resolved it, so
no two machines agree on what it declares. A value from here would be decoded over whichever
already ran, with the change visible only in memory while the file kept saying otherwise.

Two keys that no longer have any effect and two that exist to make a node misbehave are out as
well. rpc.unsafe is deliberately still declared: it is a real operator setting, and excluding
it on the grounds that it is dangerous would use this mechanism for a purpose none of its other
categories serve. The diff log below is what surfaces it.

What an operator can see

The node's own configuration file still says what it said, and every tool an operator reaches
for reads that file: a patch command, a validator, an audit, somebody reading it over their
shoulder at three in the morning. None of them describes the running node after this.

So every delivered key that moved is logged with what it moved from. Not bounded: an earlier
version capped the list at twenty entries in alphabetical order, which meant a file writing
many keys of one section dropped whichever sorted last, and for the RPC section that is
rpc.unsafe. A section's changed list is bounded by its own declaration, so the cap guarded
against something that cannot happen.

Two more things nothing else reports: a key the file writes that no section declares, and an
environment variable set for a key the environment cannot carry.

This manager's own reporting is held at a level its reports survive. One log level covers every
logger in the process and an operator writes it, and a fleet that runs its nodes quiet was
silencing all of this — measured: a value was delivered and not one line was emitted. The floor
is re-applied after anything that sets a level, because both setters reach every logger.

A disagreement about what kind of node this is

Two files state that under different names. sei.toml records it at the top and every value
resolved here is the answer for that kind. The node's own file states it again in a key of its
own, and that is what the node runs as.

This does not declare the second, on purpose: two keys for one fact can be written to disagree.
The cost is that they can still disagree across the two files, and a node resolving a
validator's values while running as a query-serving node reads correctly in every other report
about it. It is reported now, and not corrected — what kind of node this is gets decided when
it is provisioned, and a configuration manager is not the thing that should change it.

A command that answers before a node restarts

seid sei-config check resolves the file the way a boot resolves it and reports every value
this binary would refuse, without starting anything. Non-zero if there is one.

A boot may not refuse a file, so every value it cannot use is a report on a node that has
already restarted, and a fleet rolling a change forward reads it after the change is on every
node. The same questions have exact answers beforehand: the file, the binary and the
environment are the whole input. Run it first and a mistyped value costs a failed check rather
than a restart.

It needs a writable home and HOME set, because it is a subcommand of the root and the root's
own setup writes client.toml, config.toml and app.toml. A pipeline has to copy the file
into a writable directory rather than pointing at a read-only mount.

Two writers of the node's own file, measured against each other

A declared value is what the init command writes for a kind of node, and that command is not
the only thing here that writes this file: a node started without one gets it generated by the
boot instead. They disagree on four keys, and a test records which and what a node gets.

Two of the four were not what I predicted. The profiling address does not diverge, because the
boot assigns it and a bound flag's empty default overwrites it inside the same function. The
transaction indexer does, because the boot's writer produces a file for a node that serves
queries — the kind it defaults to — so that row is a validator's resolution against a file
generated for something else.

No fleet node is affected: the controller and the provisioning scripts both set the indexer
explicitly, and no fleet node lets the boot generate this file.

Verification

gofmt -s, goimports, go vet clean. golangci-lint reports only three pre-existing
reflect.Ptr findings that the version CI runs does not flag. config/..., cmd/seid/... and
app/... pass.

I also broke each property on purpose and checked which test objected. Rehearsing into a fresh
configuration instead of a copy of the node's blanks the root directories and delivers defaults
over the operator's file. Publishing a refused decode applies half a section. Delivering
defaults overwrites an unwritten key. One decode for every section makes a typo cost another
section's settings. Comparing flag names as strings loses a typed flag to the file. Accepting a
bare number for a length of time gives a node sixty nanoseconds. Accepting a negative gives it
eighteen million million million connected peers. Each was caught.

Three of those were found by a peer review that ran the code rather than read it, along with
the log level inversion and a report that claimed values matched when it had failed to read
any of them. Every one of those defects decoded cleanly and would have been logged as a
success.

Reviewing this

Its base is the last of the node-section changes, so the diff here is only this step. main
has to be merged into that stack before the first of those is retargeted.

A declared field holding an interface is now refused at registration, because what a decoder
writes into one depends on the value already there, which would make a rehearsal in a copy
answer for the copy rather than for the node. That check runs after exclusions, since an
excluded path is not declared.

bdchatham and others added 7 commits August 24, 2026 09:01
A node that selects this configuration manager reads sei.toml, resolves it
against the binary's declared defaults, and installs the result into the
source the rest of the boot reads. Selecting the manager is a switch rather
than a configuration change: a node with no sei.toml, an unreadable one, or
one naming a mode this binary does not declare defaults for installs nothing
and every key reads as it always has.

Only the keys something other than the defaults supplied are installed. A
resolution answers for every declared key, so installing all of it would
write a default over whatever an operator's app.toml holds for each of the
hundred and fifty keys their sei.toml does not mention. A key reaches the
node exactly when a source supplied it.

The flag snapshot is taken at the entry to Apply, before the handler that
copies configuration values into flags and marks them changed. After that
runs, a flag an operator typed and a key their app.toml holds cannot be told
apart, and a flag layer built from that state would put app.toml above
sei.toml.

Two things nothing else reports are logged: a key the file writes that no
section declares, and an environment variable set for a key the environment
cannot carry.

Resolve now reports an undeclared name for the file only. A node is started
with flags that were never configuration keys, so pooling all three sources
warned about thirty-nine working flags on every boot with the file's one real
typo somewhere inside the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	config/registry/spec_test.go
A value written for one of those sections resolved correctly and reached
nothing. The boot's handler reads that file once into a struct before the
install runs, and the node reads the struct, so a value put into the source
afterwards was ignored in silence.

Those sections are now decoded into that struct instead, which is the mechanism
the handler used and therefore the same casts, tags and hooks. A section
declares which of the two ways its reader takes, and the sections of this file
declare it in the same loop that registers them, so one cannot be registered
without being delivered. A section that disagrees fails a test rather than
quietly changing nothing.

Four properties hold it.

Only what a source supplied is decoded. The struct already holds what the
node's own file said, so delivering a default over it would replace an
operator's file with one nobody chose, on every boot, for every key their
sei.toml does not mention.

A section at a time. A decode is all or nothing for whatever it is handed, so
one refused value would otherwise cost every key in the file rather than the
keys of the section it appeared in.

Decoded into a copy and published by replacing it. A decoder gathers errors and
keeps going, so a refused value partway leaves its target holding some new
values and some old. The copy is of the configuration the node already has and
not a fresh one, because what a decoder writes can depend on what its target
already holds, and only a copy holds the same things. It shares nothing: a
decoder writes a list into the array its target holds, so one shared list would
edit the original.

The log level is applied from the resolution before any of this. A refusal is
reported at a level an operator may have raised the threshold above, so waiting
for a successful decode would mean the one setting somebody changes to see a
refusal is the setting a refusal suppresses.

Keys nothing in this binary writes are not declared. A cluster controller
resolves a peer set from live discovery and patches the addresses in, a node
computes a trust height and hash from the chain tip each start, and a moniker is
stamped per instance. A value from here would be decoded over whichever already
ran, with the change visible only in memory. Two keys that no longer have any
effect and two that exist to make a node misbehave are left out as well.

A field holding an interface can no longer be declared. What a decoder writes
into one depends on what the field already holds, so a rehearsal in a copy would
answer for the copy.

Every delivered key that moved is logged with what it moved from. The node's own
file still says what it said, and every tool an operator reaches for reads that
file, so this is the only place the two can be told apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A typed flag now reaches the key it carries. A flag's name and that key are not
always spelled the same: the node's own flags separate words with an underscore
where the tag they decode through uses a hyphen. Compared as strings such a flag
looked like a name nothing declares, so it was dropped and the file won over the
command line, which inverts the one channel an operator reaches for during an
incident. Matched through the environment spelling instead, where a dot, a
hyphen and an underscore are one character. The registry already refuses to let
two declared keys share that spelling, so a flag matches at most one key.

A length of time written as a plain number is refused. The file format has no
way to say how long something is, so a length of time is written as text with a
unit. A plain number decodes cleanly and reads as nanoseconds, so sixty means
sixty billionths of a second and the node starts. Nothing later objects, because
nothing later can tell. The delivery is the only place that can: the resolution
sees a number and a key, and only the struct says the key is a length of time.

The two writers of this file are measured against each other. A declared value
is what the init command writes for a kind of node, and that command is not the
only thing here that writes the file: a node started without one gets it
generated by the boot. They disagree on four keys, and the record says which and
what a node gets instead. Two were predicted and two were not. The profiling
address does not diverge after all, because the boot assigns it and a bound
flag's empty default overwrites it inside the same function. The transaction
indexer does, because the boot's writer applies none of the rules that vary a
setting by kind of node, so a validator that let it generate this file indexes
every transaction.

A command answers, without starting a node, whether this binary can use a
sei.toml. A boot may not refuse a file, so every value it cannot use is a report
on a node that has already restarted, and a fleet rolling a change forward reads
it after the change is on every node. The same questions have exact answers
beforehand: the file, the binary and the environment are the whole input. Run it
first and a mistyped value costs a failed check rather than a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two reviews ran this code rather than read it. Seven things it got wrong.

A log level resolved here beat one exported in the environment. The logger reads
a variable of its own before any of this runs, under a name that is not the one
this key answers to, and the boot's handler steps aside when it is set: a flag
beats it and a file does not. Applying regardless put the file above it, so an
operator who exported a level and adopted this file found the level ignored. A
typed flag still wins, which is the order that was already there.

This manager's own reporting is held at a level its reports survive. One level
covers every logger in the process and an operator writes it, so a fleet that
runs its nodes quiet silenced every report: what was applied, what moved, what
was refused. Measured, a value was delivered and nothing was said about it. The
floor goes on after anything that sets a level, because both setters reach every
logger.

A length of time written as zero is applied. It was refused as a plain number,
and the reason a plain number is refused is that it means nanoseconds and is out
by a factor of a billion. At zero there is no factor: several of these settings
document zero as the way to turn them off and three declare it. An operator
writing it lost every other key in the section.

A negative number is refused where the setting cannot hold one. Minus one is how
an operator says no limit in most software they have used, and the decoder wraps
it to the largest value the field has, so the ceiling on connected peers stopped
bounding anything and a window in seconds became centuries.

The report of a key nothing declares sat one line above the level, so a file
that raised the level to see its own mistakes still could not.

The reader that names what moved swallowed a failure, so a section it could not
read at all was reported as a section whose values all matched.

A bounded report could drop any key, in alphabetical order. The bound guarded
against a file filling a boot log, which a list bounded by its own section
cannot do.

Two comments an operator reads named the wrong mechanism, and one row of the
divergence record gave the wrong reason for a real difference.

A disagreement about what kind of node this is is now reported. Two files state
that under different names, and nothing compared them, so a node resolving a
validator's values while running as a query-serving node read correctly in every
report about it. Reported rather than corrected: what kind of node this is gets
decided when it is provisioned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A declared value is what a provisioning command writes for a kind of node, not
what any particular node runs, so delivering one would replace a setting an
operator never mentioned on every boot. Both deliveries avoid that by narrowing
to the keys a source supplied, and each does it in its own function.

That makes it a rule three call sites remember rather than one a single function
enforces. Until the narrowing has one home this is the guard: boot with a file
supplying one key and assert nothing else moved, in either delivery, for every
kind of node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 25, 2026, 1:32 AM

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.22772% with 138 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.70%. Comparing base (bbd07d4) to head (798f11a).

Files with missing lines Patch % Lines
cmd/seid/cmd/configmanager/tendermint_copy.go 70.58% 40 Missing and 15 partials ⚠️
cmd/seid/cmd/configmanager/tendermint.go 63.54% 26 Missing and 9 partials ⚠️
cmd/seid/cmd/configmanager/install.go 76.27% 20 Missing and 8 partials ⚠️
cmd/seid/cmd/configmanager/check.go 85.13% 6 Missing and 5 partials ⚠️
config/registry/delivery.go 84.09% 5 Missing and 2 partials ⚠️
cmd/seid/cmd/configmanager/configmanager.go 77.77% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                     Coverage Diff                     @@
##           plt-775-node-sections-4    #3999      +/-   ##
===========================================================
+ Coverage                    57.61%   57.70%   +0.09%     
===========================================================
  Files                         2230     2236       +6     
  Lines                       187690   188421     +731     
===========================================================
+ Hits                        108134   108725     +591     
- Misses                       69689    69788      +99     
- Partials                      9867     9908      +41     
Flag Coverage Δ
sei-chain-pr 71.24% <77.22%> (-28.76%) ⬇️
sei-db 69.80% <ø> (ø)

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

Files with missing lines Coverage Δ
cmd/seid/cmd/root.go 69.67% <100.00%> (+0.78%) ⬆️
config/registry/registry.go 100.00% <100.00%> (ø)
config/registry/resolve.go 98.90% <100.00%> (-1.10%) ⬇️
config/tendermintbase/tendermintbase.go 100.00% <100.00%> (ø)
cmd/seid/cmd/configmanager/configmanager.go 81.37% <77.77%> (-0.55%) ⬇️
config/registry/delivery.go 84.09% <84.09%> (ø)
cmd/seid/cmd/configmanager/check.go 85.13% <85.13%> (ø)
cmd/seid/cmd/configmanager/install.go 76.27% <76.27%> (ø)
cmd/seid/cmd/configmanager/tendermint.go 63.54% <63.54%> (ø)
cmd/seid/cmd/configmanager/tendermint_copy.go 70.58% <70.58%> (ø)

... and 2 files with indirect coverage changes

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant