Skip to content

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

Draft
bdchatham wants to merge 41 commits into
mainfrom
plt-775-node-install
Draft

[ConfigManager] Read sei.toml at Boot#3999
bdchatham wants to merge 41 commits into
mainfrom
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 30 commits August 20, 2026 14:02
Four sections, each registered by the package that owns its struct, so the
struct, the values and the keys come from one place and cannot drift apart. The
keys derive from the mapstructure tags, which is what makes the registry's
spelling and each reader's own constants the same strings, and each package's
test holds the two against each other rather than against a written-out list.

  admin_server   2 keys
  giga_executor  2 keys
  receipt-store  6 keys
  wasm           3 keys

Three register their own struct. wasm needs a schema, because the upstream type
carries no mapstructure tags at all, so keys derived from it would be field names
rather than the ones the module reads. Its simulation gas limit is text because
the field it stands for is an optional number and absent is a meaning of its own:
unset means the consensus block gas limit applies, which no number can say. Two
of that type's settings declare nothing, one having no key any reader resolves
and the other written into app.toml by the template and read by nothing.

Registering the receipt store needed a distinction the registry did not draw.
mapstructure reads a tag of "-" as skip this field, and a configuration struct
uses it for a field something else assigns: KeepRecent comes from the global
min-retain-blocks flag at the app layer, ExternalPruning from whatever constructs
the garbage collector. The registry read that as a missing name and refused the
whole section.

Such a field now declares no key, which is narrower than declaring one that
resolves to a default and safer for the same reason: a declared key is written at
override precedence, so a default would land on top of the value that code
assigned, and a node with min-retain-blocks set would silently keep nothing. A
field with no tag at all stays a defect, because that is the opposite intent, a
key nothing names reaching no field.

The skip lives in tagOf, which both walks already share, so the declared keys and
the rendered defaults describe the same fields. Reverting either walk's skip on
its own fails a test.

The recorded configuration surface does not move: nothing consumes the registry
yet, and no golden changed.

100% of statements in config/registry, race clean. Three mutations each fail a
named test: refusing a dash again, skipping an untagged field, and letting the
two walks disagree.
Four sections owned by the app package, each registered where its struct lives.

  genesis           2 keys
  light_invariance  1 key
  state-commit      20 keys
  state-store       12 keys

light_invariance registers the type its reader fills. The other three declare a
schema, because keys derived from the type the reader fills are not the keys the
reader looks up. The genesis import type carries no mapstructure tags at all.
State store and state commit both tag their fields with something other than the
name resolved from configuration, and state commit nests its settings under three
inner structs while the keys are flat names on the section, apart from the one
flat key-value setting that has a segment of its own.

State commit's write mode is text rather than the reader's named type, because
the reader parses a written name into that type itself. Declaring the named type
would have one key answer as a named string from these defaults and as a plain
one from an operator's file.

Each section's test holds its resolved keys and values against the reader's own
constants and its own defaults. Resolving is what it compares, rather than the
registered struct, because the resolved map carries the key a tag produced and
the value that tag's field held: a comparison of struct to struct agrees with
itself while two tags sit on the wrong fields, since each field still holds the
value the test names for it. Putting the genesis tags on each other's fields
leaves the key set identical and fails the test.

State store's declared defaults are not what its reader produces for a file
missing those keys. It starts from the declared defaults, then assigns eleven of
its twelve fields straight from a lookup with no check that the key was present,
so an absent key casts to a zero and clobbers the default beside it: the store
reads as disabled, with no backend, keeping every version, and committing
synchronously. Only the snapshot key is guarded, and its own comment at the read
says why. A node whose app.toml predates one of the other keys therefore runs the
clobbered value today and the declared default once something installs this
section, and guarding the remaining reads is what makes those the same thing.

The recorded configuration surface does not move, because nothing consumes the
registry on a boot path yet.
Four sections, each registered by the package that owns its struct.

  eth_blocktest   2 keys
  eth_replay      4 keys
  evm            57 keys
  evm_query       1 key

All four register the struct their reader fills. None needs a schema, because in
each of them the mapstructure tags already spell the keys the reader looks up, so
the registry derives what a node reads and nothing restates a list of fifty-seven
keys. Each package's test holds the derived keys against the reader's own
constants, which are the second statement of the same set in the same file: a
rename that moves one and not the other compiles.

The EVM section resolves the same values for every mode. Nothing consults a
node's kind while reading these keys, so a file missing them serves both
interfaces whatever kind of node it is, and that is what these resolve to. A node
seid init provisioned is the other case and needs nothing here, since that path
writes the two interface toggles per mode and a written value is what resolves.

Two of that section's values come from the machine rather than from a decision.
The simulation call limit is the processor count and the worker pool is twice it,
capped, so they describe whichever host resolved them. That is stated where they
are declared, because a caller rendering them into a file carries one host's
sizing to whatever reads that file next.

Replay declares three of its four keys under the name the template writes and one
under a different one. The template renders eth_replay_contract_state_checks and
the reader looks up contract_state_checks, so every generated file already carries
a name nothing resolves. The declared key is the one a value reaches a reader
through, and a test refuses the other: declaring it would add a key an operator
can set and no reader answers, which is worse than the mismatch, because the value
would look as though it applied.

The recorded configuration surface does not move, because nothing consumes the
registry on a boot path yet.
Five sections whose keys belong to the Cosmos server, and the two registry
capabilities they need.

  api            8 keys
  base          14 keys
  grpc          11 keys
  state-sync     3 keys
  telemetry      7 keys

These sections have no owning package here. Their structs and their readers live
in sei-cosmos, which this repository vendors rather than authors, so there is
nowhere upstream to put a registration this registry would see. Four of the five
register the upstream struct directly, because its mapstructure tags already name
the keys the reader resolves.

A section can now declare keys at the root of the file. The node-wide settings are
written at the top of app.toml and read as pruning and halt-height, with no
segment in front, so a section carrying a name into every key would rename all
fourteen and an operator's existing file would reach none of them. A section
therefore has a name it is looked up by and a prefix its keys carry, and for a
root section the prefix is empty. Both walks build a key through one function, so
a root key gains no separator on either side; reverting either one on its own
fails a test, the value walk through the check that a rendered default states one
value per declared key.

Two keys can now collide where two prefixes never could. A key two sections both
declare has one default rendered over the other, and which one depends on the
order the sections are walked. And a root key that is also a section's name cannot
be written at all, because a file holding both a value for that name and a table
under it is not valid TOML, so one of the two is unreachable and nothing says
which. Both are refused, in either registration order.

A section can now say that an environment variable cannot supply one of its keys.
The metric label set is a list of name and value rows and its reader asserts that
exact shape rather than casting what it finds, so no single string satisfies it,
and the assertion is the first statement of the whole server configuration. A
resolved variable would install a value that stops the node; leaving the channel
out means the file's value applies and the node runs. The reason is required
rather than optional, because an operator whose variable is ignored has to be told
why, and a refusal with no reason is itself refused.

The metric section is the one here that needs a schema, and for one field's shape
rather than for a spelling. Its label set is declared as untyped rows to match
what the reader takes. A test holds every other field to the upstream field's name,
tag and type, and holds the count of differing types at one, so a second
divergence is a failure and a converged upstream type leaves the schema with
nothing to justify it.

Nothing here varies a default by mode. seid init writes the two interface toggles
and the block retention per mode, so a node it provisioned carries those as
written values, and these are what a node with nothing written runs.

One declared value is not what a running node uses, and it is worth knowing which.
The pruning strategy is declared as keeping everything, while the command line
registers a flag of the same name defaulting to the standard strategy, and a bound
flag is a source of its own below the file. A node started with no pruning key
written prunes on the standard schedule. Whoever resolves for a running node has
to supply the flag values to get the answer that node uses.

The recorded configuration surface does not move, because nothing consumes the
registry on a boot path yet.
The rule was right and the reason recorded for it was not, which matters
because it is the reason the remaining sections will cite.

It said a declared key would be written at override precedence and land on
top of the value that code assigned. That cannot happen for either field it
named: the app layer assigns the receipt store's retention after the reader
has returned, so the assignment is last and wins. It also said such a node
would silently keep nothing, and the field's own comment says the opposite,
that keeping zero versions means keeping everything. An operator handed that
sentence during an incident looks for missing receipts and finds a full disk.

The reproducible reason is the one every other refusal here rests on. A key
for such a field is one an operator can write that the assignment then
discards, so it reaches no field. A field with no tag stays a defect for the
same reason read from the other end, because it would declare a key derived
from a field name and no operator writes that. The two look alike in a diff
and mean opposite things, which is why the package's own contract now states
the distinction rather than leaving it in a comment beside one branch.

Two of the four sections held their declared keys against a written-out list
of the same strings. That is a second statement of the key set, which is what
a section exists to remove: a tag and the list move together and the reader
keeps asking for the old spelling. Both now hold against the constants their
reader passes to Get. The admin server's reader was spelling its two keys
inline, so it has constants for them now, and its registration no longer
recites the derived keys in prose that drifts the moment a tag moves. One
assertion is gone because it could not fail, the exact key set having been
compared three lines above it.

Two declared values are also stated elsewhere in the binary, and each now
says so where it is declared. The wasm query gas limit resolves to ten times
what the template writes into a generated file, so a node provisioned by the
binary runs the smaller number and a node whose file predates the section runs
the declared one; whoever renders declared values into a file has to decide
which survives, and that limit bounds the work one smart query can ask of a
node serving queries to anyone. The receipt store's database directory
resolves to an empty string, and the emptiness carries the meaning: the app
layer fills it from the host, keeping the former path for a node that already
holds the store there. A path written into a file is one host's answer and
names an empty directory on another.

One comment said the contract debug switch has no key any reader resolves. It
is read from the node-wide trace flag, so its key belongs to the root of the
file rather than to that section, which also means the section's three keys do
not determine the configuration the module ends up with.

A new test asks the whole set at once. Two refusals depend on what else has
registered, neither is visible from inside either section, and the section
that loses is dropped whole with every key it declared. Registering a section
whose key collides with the receipt store's leaves all four section suites
green and fails only this one.
Two of the state store's settings mean something different depending on what
kind of node asks, and this section answered the same for all four. An archive
node exists to keep history, and it was declaring a retention of a hundred
thousand versions. The binary already says otherwise, in the mode rules it
applies when it writes a file: an archive node keeps everything, and a
validator and a seed run with the store off. Those rules are now where this
section's answer comes from, so a change to them moves this too.

That is the correction the pinned defect asks for. The record of it says to
pin how configuration resolves today and correct it in the versioned manager,
and this registry is the versioned manager, so declaring the rendered value
would have pinned the defect a second time in the place meant to fix it.
Nothing runs differently yet, because nothing consumes the registry on a boot
path, and the direction matters more than the timing: state store pruning
deletes inside the store on a timer, the archive volumes are protected against
being deleted rather than against being emptied, and pruning frees disk so
nothing that watches disk would fire.

The rest of this change replaces prose with measurement. The declared values
for the two storage sections are not what their readers produce for a file
missing the keys, and the comment describing which keys those are was wrong
three ways: it named four of the six store settings, missed two, named a
commitment setting that does not in fact differ, and missed the one that
selects how a node commits. The write mode is read through a presence check
and then rewritten unconditionally, so a node with nothing written commits in
the derived mode rather than the one that key carries.

A comment cannot fail when it is wrong. So the set is measured against the
readers now, per mode, and recorded as data: a key that starts diverging fails,
and so does one that stops, which means guarding a read has to account for its
row rather than quietly making a sentence stale. Each key set is also held
against this package's own read-site record, which is kept for another purpose
and held against a golden file, rather than against a list written beside it in
the same commit. The record spells its keys with the reader's constants and the
section derives them from tags, so a rename on either side alone fails.

One comment said the other keys under the commitment section's flat key-value
name have no reader. Four of them are read by the Cosmos server's own reader,
so they belong to whoever registers that section, and saying they reach nothing
would have closed the door on declaring them.

The whole-registry defect sweep here is now scoped to the four sections this
file registers. A refusal that depends on what else has registered is not this
package's to answer for, and the sweep that covers it lives where every section
is linked.
Four comments said more than their subject. The light-invariance default now
states the mode decision and leaves what the check compares to the check. The
genesis schema says why it exists and leaves what holds it to the test that
holds it. The helper that resolves says once that it renders every section, so
a failure naming another one is read correctly. And the write-mode default is
asked for every mode rather than one, which is how the tests beside it ask.
The failure messages for an excluded field still claimed the mechanism the
comment above them no longer does: that a declared key would land over
whatever assigns the field. They now say what is true, that such a key is one
an operator can write and the assignment discards.

The half of that test covering an untagged field asserted only that some
refusal was recorded, so it passed on a refusal raised for any other reason.
It now requires the message to name the field, and changing the name it looks
for fails it.

The registry's contract adds the one thing its new rule leaves unsaid: the
exclusion tag is meaningful only on an exported field, because an unexported
one carrying any tag is refused before the tag is read.

The giga executor's default says why this section does not vary by mode
without stating it as a rule for every section, since another section in this
work does vary and the binary is what decides which. The fixture in the
registry's own spec no longer describes that package as one that would
register, because it now does.

The wasm section's defaults function takes a name that does not collide with a
local of the same name elsewhere in that package, and its test asks every mode
rather than one.
Three of these settings mean something different depending on what kind of node
asks, and all five sections answered the same for every one. The binary already
states the rules, in what it applies when it writes a file, and every section
here now answers through them.

Each of the three matters in a different direction. A full node and an archive
node exist to serve queries, and both interfaces that serve them were declared
closed. A validator is meant to expose as little as it can, and gRPC was
declared open on every one of them, which is the opposite of what the rule beside
it says it is for. And the number of blocks a node retains was declared as
keeping everything for a full node, where the rule prunes at a hundred thousand.

The rules are read rather than restated, so one added later moves these sections
with nothing here changing, and the test writes the three values out by kind of
node so a change to the rules fails and gets looked at. Resolving every mode as a
validator, opening gRPC on a validator, and changing the retention each fail it.

The two sections no rule touches answer through the same function, so there is
one place a mode is applied rather than a decision per section about whether to
apply it.
…aces per mode

A resolved list was the section's own list. A section's default is usually a
package-level variable, so a slice field handed out the array that variable
holds, and one in-place write by a caller rewrote it for the whole process:
every later resolution, and every reader that copies the same struct. Two of
the five lists this reaches are deny lists, so the rewrite is silent and it is
a security control. The registry already copies a section's keys for exactly
this reason and said so in a comment; values now get the same guarantee, at the
one function both walks pass through. Removing it fails a test that writes into
a resolved list and asks the section's default what it holds.

The EVM interfaces answer per kind of node. A full node and an archive node
serve queries, which is what those interfaces are for. A validator and a seed
serve none, and this section declared both open for every kind, which puts a
public request surface on the node that holds a signing key. The rule already
exists in this binary. It could not be read from here, because the package that
owns the node mode imports this one, so the rule moved to the registry, which
is a leaf both sides reach, and the node-mode type now delegates to it rather
than stating it twice. Forgetting archive in that one statement now fails a
test.

Each section also holds its keys to the values their fields hold, not to its
own defaults struct compared with itself. That comparison agreed with itself
while two tags sat on the wrong fields: the key set stays identical and every
field still holds the value it always did, so an endpoint and a directory, or a
deny list and an origin list, change places unnoticed. Both of those swaps now
fail.

Each section reports its own refusal. A registration the registry cannot use is
recorded rather than raised, and these tests inferred it from a lookup coming
back empty, which threw away the sentence saying why.

Two tests are gone because they restated checks that already exist a few files
away, where the message is better. Two comments are corrected: enabling replay
opens a client without reaching the endpoint, so an unreachable one surfaces
during replay rather than at startup, and the two machine-derived values are
not one case, because the worker pool re-measures when it is given a value that
is not positive and the simulation limit reads zero as no limit at all.

The registry now states what a resolved value's type depends on, because it
resolves values and does not convert them: a default arrives as its field's
type, a file as whatever the format decodes to, and an environment variable as
one string.
…d nothing

A resolution answered for any string. A section's defaults answer per mode, and
a mode this package does not know reached whatever each section does with an
argument it cannot match, which for these five is the rules answering as though
it were a full node. So an empty string, a capitalised name, or one with a
trailing space resolved the interfaces a full node serves onto whichever node
asked, with no error. It is refused now, naming the four.

A refusal of the environment channel is recorded by a key, so a slip in the
spelling named a key no section declares. The channel would never have offered
that key, so the refusal covered nothing while reading as though it did, and the
key it was written for went on resolving from a variable. Both sets exist for
the first time when something resolves, because a refusal may be recorded before
the section declaring its key registers, so that is where they are compared.

The reason a refusal carries is required and had no consumer. The channel was
skipped before the variable was read, so the one fact a diagnostic needs, that
an operator set it, was discarded at the cheapest possible point. The variable is
read now and its value still thrown away, and the key comes back named, so a
required reason is one somebody can be told. A refused key nobody set is not
reported, because a value nobody chose is not news.

A refusal also names the section that declares the key, so a refused key is
attributable the way every other defect is. It was putting the key where the
section belongs, which made a defect read as though a key had registered and
made a scoped sweep skip it.

Four of the metric section's seven hand-copied values were held against nothing.
That is the one section here that has to restate its values, so it is the one
where a field can be assigned from its neighbour, and assigning the hostname
toggle from the enabled toggle survived the suite. Every one of the seven is now
held as the key it resolves to rather than as a struct field, because a struct
compared with itself agrees while two values sit on the wrong fields.

Five comments said things the code does not. The node-wide settings claimed to be
unchanged by mode while one of their own keys answers per mode. A count of
non-zero defaults was wrong. Two different counts of six read as one, and the
pair the sentence lost is read through a clamp that does nothing for an absent
key. The package's reason for existing named a vendored tree, when other sections
register inside one and the real obstacle is an import edge. And a paragraph
named two sections that belong to another change.
Four statements in the package contract described the previous shape. A second
entry point exists, for the settings written at the top of a file rather than
inside a table, and the contract showed one. The list of what makes a
registration unusable no longer enumerated: two sections declaring one key and a
top-level key sharing a section's name both became possible once a key could sit
at the root, and a refusal of the environment carrying no reason is refused too.
The resolution order had gained a per-key hole in one channel and did not say so.
And the first step of adding a section told an author to use the name as the
first segment of every key, which is false for a section whose keys have none.

A mode this package does not declare is also refused now, and the contract says
that where it says a default answers per mode.
refuseOverlap refused two collisions and only one of them could happen. Two
sections declaring one key is already refused by the environment check, which
two identical keys reach by answering to one variable, so that arm was a second
guard on a case already covered. The other arm, a key at the top of the file
sharing a section's name, was the only one it alone caught, and it has no
instance: one section declares keys at the top of the file and none of its
fourteen names is a section's.

So the code goes and the fact stays. The contract names the collision among the
things this package does not guard, with what makes it reachable, because a
second such section is where it starts to matter. The prototype found that out by
hand: it named the section holding config.toml's top-level keys after the file
rather than after the node, because the client file declares a top-level key
called node and a node section could not have coexisted with it.

The one case the removed guard described better is named better now where it is
still refused. Two identical keys were being reported as two spellings of one
environment variable, and the reason a dot and a hyphen are the same character to
the environment is not the reason a key collides with itself.
…sures where a node differs

These sections said their values were what a node with nothing written resolves.
They are not, and the difference was carried in four paragraphs of prose with one
of the counts wrong.

What they are is what seid init writes for a kind of node: the upstream defaults
with the binary's own mode rules applied is exactly the pipeline that renders a
generated app.toml, so a declared value is what that file would have held. That
is a claim about a real pipeline in this binary rather than a judgement, so it can
be held, and it is what a caller writing a configuration file wants.

Where a node with nothing written resolves something else is now measured. The
reader is driven with the start command's flags bound, the way a booting node
binds them, because seventeen of these keys are also flags and a flag's
registration default is what an absent key reaches before the lookup comes back
empty. Twelve keys differ, and the measurement corrected the prose twice over:
seven keys the paragraphs implied differ do not once the flags are bound, and the
gRPC toggle does, which no paragraph named. Of the two interface toggles it is the
only one that diverges, because its flag defaults the interface on while a
generated validator file writes it off.

A key that starts diverging fails, and so does one that stops, so guarding a read
has to account for its row. Dropping the flag binding fails it too, which is what
keeps the record measuring what a node gets rather than what the reader says in
isolation.
This binary writes an app.toml two ways and they disagree on four keys. The
provisioning command applies the mode rules and renders the result; a node
starting without a file runs a second pipeline that applies no mode rules at all
and carries overrides of its own. So it writes the standard pruning strategy
where the command writes keeping everything, a metric retention of sixty against
seven thousand two hundred, the REST interface on for a validator against off,
and a pruning interval drawn at random on every run.

A declared value follows the command an operator runs to provision a node. That
was already true and the comment said only that seid writes it, which is ninety
per cent of a fact.
A declared value is what the seid init command writes for a kind of node. This
section departs from that once: the retention an archive node keeps. The mode
rules set it to keep everything and the command does not write that, because the
type it renders declares a state store field of its own and fills it from the
mode-blind default, so the rule is applied and then discarded.

PLT-955 records that and records the decision, which is to pin what a node
resolves today and correct it in the versioned declaration rather than at the
point that loses it. So the departure is intended, and it is now held rather than
asserted. It fails if the command starts carrying the rule, which is the day the
departure should be deleted. It fails if this section stops departing, which
would declare a retention on the one kind of node whose purpose is keeping what
it would prune. Both directions are checked, because a departure nothing measures
cannot be told from an oversight.
These four said their values are what a node with nothing written resolves. They
are what the seid init command writes for a kind of node, which is a different
statement and a checkable one: that command applies the same mode rule to this
section's own defaults and renders the result, and for the EVM section it passes
what it applied through rather than refilling it from a mode-blind copy, so a
declared value here is the value that reaches the file.
…de runs

The section declared this module's own default and the command writes a tenth of
it, so the two disagreed by a factor of ten on the only bound on the work one
smart query can ask of a node that serves queries to anyone. Declaring the larger
one meant a caller rendering a file from these values would loosen that bound,
and a test named for the module's default invited exactly that reading.

The number now lives once, beside the section, and the command reads it. So a
declared value is what reaches an operator's file, and the generated file is
unchanged, which the command's own characterization suite confirms. Raising it to
the module's default fails a test rather than silently widening a public surface.

The module's default is still what a node whose file carries no wasm section
resolves. That is a different question, and it is held as one.

Three other sections say what their declared values are rather than what a node
with nothing written resolves.
# Conflicts:
#	config/registry/doc.go
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>
tagOf carried its rationale inline, which meant the steps were never named.
Each condition that needed explaining is now a predicate whose name says why
it is there, and the rationale moved to that predicate's doc comment.

The tag excluding a field from configuration and the spelling no written key
can match are the two that carried comments. Splitting the tag into a name and
the squash option is a step of its own, so the body reads as a sequence rather
than parsing in place.

Behaviour is unchanged and no test moved.

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

The node's own configuration file carries 141 keys and no section declared any
of them. These two are the first, and the registry needed two things before
they could be.

A section can now leave out a path the struct carries. Two kinds of field earn
it. One a reader refuses outright, where writing the key stops the node, so
declaring it would put a setting in the space whose only effect is an outage.
And one whose absence is itself the setting, where a default would be this
package inventing one. An exclusion naming no field the struct carries is
refused, because the field it named can be renamed away and leave the exclusion
reading as a deliberate omission while excluding nothing.

A field that collects what the decode matched no field for now declares no key.
What lands in it is what an operator misspelled, so giving it a key would offer
the collector itself as a setting to write.

Neither the package defining these settings nor the package deciding them can
register them: the struct belongs to the node's configuration package and the
rules that vary it by node kind live in the parameters package, which imports
that struct. So a third package does it, the same shape the upstream server
sections already use.

Four keys vary by node kind and a test holds all four by value. Two are listen
addresses, so a rule that stopped varying would leave a validator binding the
address a query-serving node binds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither varies by node kind. How long a node waits at each step of a round has
to agree across the validator set for the set to reach a decision, and what a
node holds before a transaction is decided is a limit on its own memory.

The consensus struct carries fifteen fields the node removed as settings, and it
marks each one deprecated. They are excluded: declaring one would offer a key
that changes nothing about how the node runs. So the section declares nine of
its twenty-four paths.

The reader has a check that names the removed settings an operator wrote, and it
reaches eight of the fifteen. Six are durations or booleans, where a written
zero and an unwritten field hold the same value, so no check can tell them
apart. One more it omits. Nothing calls the check in any case. A test holds
which eight it reaches, so making it complete fails rather than leaving the
count stale, and a second test holds every exclusion to the struct's own
deprecated marking rather than to that check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
State sync, the transaction index, instrumentation, the signing key paths and
self remediation. That is every table in the node's own configuration file.

The transaction index varies by node kind: a node that serves queries indexes
transactions so it can answer them, and a validator and a seed serve none, so
they index nothing and keep the write.

State sync leaves one path out. The servers to fetch a snapshot from are the
operator's own peers, so there is no value to inherit, and an address written
here would name a host this binary cannot know about.

A test now walks the section names this package owns rather than a list kept
beside them, so the next section is covered by registering it. Another asserts
the registry refused nothing, which is the check no single section can make:
two of the refusals depend on what else has registered, and a section that
loses is dropped whole rather than reported by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourteen keys sit at the top of that file with no table of their own. They are
declared against a schema rather than the node's top-level type, because that
type carries the nine tables as well and declaring against it would declare
every table's keys a second time. The schema squashes the same base group that
type squashes, so those spellings still come from the node's own tags, and it
restates the two fields held beside that group. A test holds those two to the
type they came from by name, tag and type, and holds the count, so a third one
appearing there fails rather than going undeclared.

Two paths are left out. The home directory is where this file is found, so a
value inside it would be the file naming its own location, and the command line
already carries it. The node mode is the fact the file states at the top under
its own name, and a second spelling would let the two disagree, with the
resolution answering for one and the node reading the other.

A test also checks that no root key is another section's name. Nothing refuses
that collision, and the two settings it produces cannot both be written because
no file holds a value for a name and a table under it. This is the first change
to declare root keys beside another package's, so the check lives here until it
has somewhere better to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five of these sections carry a root directory field tagged the same as the key
at the top of the file, and the node fills every one of them from the command
line after the file is read. So the file never carries the value and each
section states the empty string for it.

Declaring it hands whatever delivers these values an empty root to write over a
running node's, and a node that cannot find its data directory, its genesis file
or its signing key does not start. Two of the five are here.

The test checks every registered section rather than the five, so a section
added later that carries the same field fails rather than shipping the same
hole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bdchatham and others added 10 commits August 24, 2026 12:15
# Conflicts:
#	config/tendermintbase/tendermintbase.go
#	config/tendermintbase/tendermintbase_test.go
…ory out

Both carry the root directory field the node fills from the command line after
the file is read, so both stated the empty string for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last of the five sections carrying the root directory field the node fills
from the command line after the file is read. The signing key section is the one
where a blank root is worst: a node that cannot find its key does not sign.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	config/tendermintbase/tendermintbase_test.go
# 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

github-actions Bot commented Aug 25, 2026

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, 5:21 PM

@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 (42e6711).

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.

The same rename as its sibling. This record holds what the boot's own writer
produces for a node that arrives without a configuration file, so it says that,
and the explanations beside it are reasoning.

Named for that writer rather than for the manager, because the two records
measure different things: one is what the manager this replaces resolves, this is
what the second of the binary's two writers generates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from plt-775-node-sections-4 to main August 27, 2026 18:48
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