Skip to content

[ConfigManager] Register Sections 4/4 - #3977

Open
bdchatham wants to merge 9 commits into
mainfrom
plt-775-sections-4
Open

[ConfigManager] Register Sections 4/4#3977
bdchatham wants to merge 9 commits into
mainfrom
plt-775-sections-4

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Registers the five configuration sections whose keys belong to the Cosmos server, and
adds 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 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. A section that carried its name into every key
would rename all fourteen, and an operator's existing file would reach none of them. So
a section now has a name it is looked up and reported under, and a prefix its keys
carry, and for a root section the prefix is empty.

Both walks build a key through one function. Reverting either one on its own fails a
test: the type walk through the key comparison, and the value walk through the existing
check that a rendered default states one value per declared key.

One collision is refused, and one is a gap we are tracking

A key two sections both declare has one default rendered over the other, and which one
depends on the order the sections are walked. That is refused — two identical keys answer
to one environment variable, so the check that already refuses a spelling collision
catches it. It now says so in the right terms: a key colliding with itself is not two
spellings of one variable.

The gap. A key at the top of the file that is also a section's name cannot be written
at all, because no file holds both a value for that name and a table under it, so one of
the two settings is unreachable. Nothing here refuses that.

It is not silent, which is a correction to what this said before. Whatever installs a
resolution refuses it: a source holds one value per path, so the install names both keys and
stops rather than choosing which survives. I verified that rather than taking it on
reference. So the failure arrives late, on a booting node, instead of in the registration test
of the package that caused it — and that is the cost of tracking the gap rather than guarding
it, stated where a reader will look for it.

I had a guard for it and removed it, because it has no instance: one section declares keys
at the top of the file and none of its fourteen names is a section's name. It becomes
reachable at the second such section, and the prototype shows that is exactly where it
bites — 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 node and a node
section could not have coexisted with it. So the guard belongs with the change that adds
the second root section, where the naming decision it forces actually arises.

The fact is recorded in the package contract, among the things the registry does not
guard, with the condition that makes it reachable. It is adjacent to PLT-1039, which
tracks the same shape of problem inside a single section: a leaf and a subtree declared
under one tag.

A refusal names the section that recorded it

Two smaller corrections from review, both in the same posture: this package's stated position
is that every refusal explains itself.

A refusal built its message from the key prefix, which is empty for a section whose keys sit at
the root of the file. Those messages read .HaltHeight has no mapstructure tag, naming no
section at all. The message path is now seeded with the section name and grown by the same tag
segments the key prefix is grown by, so a squashed field adds nothing and a nested one adds its
own. A root section's refusal reads node_base.Untagged has no mapstructure tag now.

Threading that was not simply passing the name through. My first attempt used the accumulated
Go field path, which made a nested refusal read nest.Mid.Deep.Untagged and a squashed one
sq.Base.Untagged; the two existing tests for those paths caught both.

And the section a refusal from the environment is recorded against was accepted and then
discarded, while the comment beside it claimed a refused key is attributable to a registration.
It is kept now and named in the one error where attribution is worth anything: a refusal whose
key no section declares, which is the misspelling case, where what an author needs is which
registration to look at.

A section can say the environment cannot supply a key

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. That
assertion is the first statement of the whole server configuration, so a resolved
variable installs 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 one schema, and what holds it

The metric section needs a schema, 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 exactly one. A second divergence fails, and if the upstream type ever comes to
match, the schema is left with nothing to justify it and that fails too.

These five are declared and not yet linked

Nothing imports config/cosmosbase, so its init runs only in its own test binary and these five
sections do not enter a real seid. That is deliberate for now: adding a blank import somewhere in the
command tree would put fourteen node-wide keys into the key space a node resolves, which is a behaviour
change and belongs with the work that consumes them rather than with the work that declares them.

Two consequences worth stating rather than leaving to be discovered. A diagnostic run against a real
binary will not see these keys. And the cross-section refusals — the two that depend on what else has
registered — are exercised against probes in the registry's own tests and never against the real
registered set, because that set and these five are never in one binary. Linking them is the first step
of whichever change consumes the registry.

One declared value is not what a running node resolves, and several more are not either

pruning is the one worth naming: the command line registers a flag of the same name defaulting to the
standard schedule, and a bound flag is a source below the file, so a node started with no pruning key
written prunes on that schedule while this declares it keeps everything. It is not the only one — a
command that assembles the server configuration overrides some of these before a node starts, and the
count is measured rather than asserted here.

A caller resolving for a running node therefore has to supply that node's flag values, and only the ones
an operator actually set. A flag nobody typed still reports a default, and this resolution ranks flags
above the file, so passing defaults would put every one of them over an operator's own value.

What a declared value is, and what it is not

A declared value is what seid init writes for a kind of node. That is not a judgement: the defaults
these sections answer through are the upstream defaults with the binary's own mode rules applied, which
is the pipeline that command builds and renders. So the claim is checkable, and a test holds it.

It is deliberately not what a node with nothing written resolves. Twelve keys differ, and the set is
measured rather than described — 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 first. Measuring it corrected an earlier draft twice: seven keys the prose
implied differ do not once the flags are bound, and grpc.enable does, which no paragraph had named. Of
the two interface toggles it is the only one that diverges.

One more thing worth knowing, and it is pre-existing rather than introduced here. This binary writes an
app.toml two ways and they disagree on four keys, because the path a node takes when it starts without
a file applies no mode rules at all and carries overrides of its own: the standard pruning strategy
rather than keeping everything, a metric retention of 60 rather than 7200, the REST interface on for a
validator rather than off, and a pruning interval drawn at random on every run. These sections follow the
provisioning command, which is the one an operator runs deliberately. A node that got its file the other
way already has a file, so a generate path is not its path.

What I broke on purpose

Eight mutations, each caught: porting the key-joining into only one of the two walks
(both directions), rendering a root section's defaults under its lookup name, dropping
the collision refusal, dropping the environment skip, allowing a refusal with no reason,
resolving the label set as the shape its reader refuses, and giving the node-wide keys a
section prefix.

Two things to know

One declared value is not what a running node uses. The pruning strategy is declared
as keeping everything (DefaultConfig()), 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. Nothing depends on this yet; I can file it if it should be
tracked.

Three defaults vary by kind of node, and every section here answers through the mode
rules the binary already applies rather than restating them. A full node and an archive node
exist to serve queries, so both interfaces that serve them are on; a validator is meant to
expose as little as it can, so both are off, and the upstream default would have declared
gRPC open on every validator. Block retention follows the same rules. The test writes the
three values out by kind of node, so a change to the rules fails it and gets looked at.

The recorded configuration surface does not move, because nothing consumes the registry
on a boot path yet.

The manifest that states which sections a binary declares is deliberately not here. It
has to name every section, so it can only land once these four registration changes are
all on main.

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.
@github-actions

github-actions Bot commented Aug 20, 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, 1:01 AM

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 57.60%. Comparing base (447f79a) to head (299d541).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3977      +/-   ##
==========================================
- Coverage   59.08%   57.60%   -1.49%     
==========================================
  Files        2305     2230      -75     
  Lines      197017   187543    -9474     
==========================================
- Hits       116414   108038    -8376     
+ Misses      69842    69656     -186     
+ Partials    10761     9849     -912     
Flag Coverage Δ
sei-chain-pr 100.00% <100.00%> (?)
sei-db 69.80% <ø> (-0.22%) ⬇️
sei-db-state-db ?

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

Files with missing lines Coverage Δ
config/cosmosbase/cosmosbase.go 100.00% <100.00%> (ø)
config/registry/environment.go 100.00% <100.00%> (ø)
config/registry/registry.go 100.00% <100.00%> (ø)
config/registry/resolve.go 100.00% <100.00%> (ø)

... and 298 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.

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.
…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.
@bdchatham
bdchatham marked this pull request as ready for review August 24, 2026 18:03
@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Registry and declaration-only changes with no seid import of cosmosbase yet; resolve behavior is stricter for unknown modes and env refusals, which matters once callers adopt it.

Overview
Adds config/cosmosbase, which registers the five upstream Cosmos server sections (base, api, grpc, telemetry, state-sync) with mode-aware defaults from params.SetAppConfigByMode. Base uses the new root-key registration so keys like pruning stay unprefixed; telemetry uses a small telemetrySchema (only global-labels as []any) and RefuseFromEnvironment for telemetry.global-labels so env strings cannot brick startup.

Extends config/registry with RegisterRootKeys, Section.Prefix, RefuseFromEnvironment, Resolved.Ignored, refusal of unknown Mode values at resolve time, and detection when two sections declare the same key. Key derivation and default rendering share join so root and prefixed keys stay aligned.

Tests lock declared keys to server readers, mode-specific API/gRPC/retention defaults, env refusal behavior, and a measured set of keys where seid init defaults disagree with what a booting node resolves when start flags are bound (agreement_test). cosmosbase is not imported by seid yet, so these sections only load in that package’s tests until a follow-up wires the registry into the binary.

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

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Registers the five Cosmos-server sections and adds root-key registration plus a per-key environment refusal; the key derivation, value walk, and mode gate all line up and the tests pin the behaviour closely. No blockers — three non-blocking points about a documented gap that is partly already covered downstream, an argument whose documented purpose is dropped, and diagnostics that lose their section for root sections.

Findings: 0 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • None at the file/PR level.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] sei-cosmos/server/config/config.go:445 — GetConfig does labelsRaw[0].(string), labelsRaw[1].(string) on a telemetry.global-labels row after only checking the row is a 2-element []interface{}, so a file writing e.g. global-labels = [[1, 2]] panics at startup instead of returning the "failed to parse global label" error the neighbouring checks produce. Noticed while verifying the telemetry schema rationale in this PR; pre-existing in the vendored tree.

Comment thread config/registry/doc.go Outdated
// - Not a file format. Nothing here reads or writes a configuration file.
// - Not a validator. A section may state rules about its own values; this package invents none.
// - Not wired. No section is registered by this package and no reader is migrated onto it.
// - Not a guard against a key and a table sharing one name. A key at the top of the file that is also

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The gap is real at registration time, but "nothing says which" overstates it. appopts.refuseUnwritable (config/appopts/appopts.go:149-157) already refuses exactly this pair: for a declared telemetry.enabled, dottedPrefixes yields telemetry, and a declared root key of that name hits note(under, key, true), so Install fails naming both keys as "both declared". So the collision does not silently make a setting unreachable — it surfaces later, as an Install error on a booting node rather than as a Defect a package's own registration test would catch.

Two things worth doing: correct this bullet to say where the collision does surface (otherwise the next reader concludes the failure mode is silent), and consider whether the check belongs in record alongside the other structural refusals, so the failure lands at the same door as the duplicate-key and env-spelling refusals rather than one layer down. Codex raised the same collision independently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and I verified it rather than taking it on the reference: refuseUnwritable builds %q and %q, both declared for exactly this pair, so Install fails naming both keys.

The bullet now says the failure surfaces there, and what that costs: it is late, arriving on a booting node rather than in the registration test of the package that caused it.

On moving the check into record — I have not done that, and it is a decision rather than an omission. The guard existed earlier in this work and was deliberately removed, with the collision recorded as a tracked gap on the grounds that it has no instance today: one section declares root keys and none of its names is a section name. record could certainly do it, and it is where the sibling structural refusals live, so I agree that is the right home if we decide to guard rather than track. Flagging it to the DRI rather than reversing that call in a review reply.

Comment thread config/registry/environment.go Outdated
// start, so the difference is recorded rather than assumed. A value silently doing nothing is the failure
// this whole surface exists to remove, which is why the reason is required and not optional.
//
// section is the section that declares the key, so a refused key is attributable to a registration the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] "section is the section that declares the key, so a refused key is attributable to a registration the way every other defect is" — but section is only read in the reason == "" branch. On the recording path it is discarded: envCannotDeliver is keyed by key alone and EnvCannotDeliver() returns key→reason, so nothing can attribute a refusal to a section.

That matters in the one place attribution would help: resolve.go:99's "refused from the environment and no section declares it" error names the key but cannot say which registration recorded the refusal, which is the mis-spelled-key case TestARefusalNamingAKeyNothingDeclaresIsRefused covers. Either store the section beside the reason and name it in that error, or drop the parameter and this paragraph.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right — it was read only on the empty-reason branch and discarded on the recording path.

Kept now, in a map beside the reason, and named in the one error where attribution is worth anything:

%q is refused from the environment by section %q and no section declares it, so the refusal covers nothing

That is the misspelling case, where what an author needs is which registration to look at. EnvCannotDeliver() keeps its key-to-reason shape so nothing else moved; RefusedBy(key) is the new accessor, and Reset clears it alongside.

Comment thread config/registry/registry.go Outdated

var keys []string
if err := walk(t, section, &keys, map[reflect.Type]bool{}); err != nil {
if err := walk(t, prefix, &keys, map[reflect.Type]bool{}); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] prefix is now doing two jobs: it builds the key, and it is the label every diagnostic below formats. For a root section it is "", so a malformed registration reports .HaltHeight has no mapstructure tag ... (registry.go:269, 337, 351, 357, 360, 365) and, at the top level, is struct {...}, which contains itself (registry.go:255) — same for the value walk at resolve.go:258, 287. The section name is right here in deriveKeys but is not threaded through.

Passing the label separately from the key prefix (e.g. walk(t, name, prefix, ...), defaulting the label to the section name when the prefix is empty) keeps these messages self-explanatory. Defect.Section still carries the name, so the impact is bounded — but it is a refusal message in a package whose stated posture is that every refusal explains itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The message path is now seeded with the section name and grown by the same tag segments the key prefix is grown by, so a squashed field adds nothing and a nested one adds its own.

Before: .HaltHeight has no mapstructure tag ...
After: node_base.Untagged has no mapstructure tag ...

Threading it was not quite a matter of passing the name through. My first attempt passed the accumulated Go-field path, which made a nested refusal read nest.Mid.Deep.Untagged and a squashed one sq.Base.Untagged — the two existing tests for those paths caught both, since they assert the tag-derived path and that a squashed field adds no segment. The label now grows exactly as the key prefix does.

A refusal built its message from the key prefix, which is empty for a section
whose keys sit at the root of the file. Those messages read ".HaltHeight has no
mapstructure tag", naming no section at all, in a package whose stated posture is
that every refusal explains itself. The message path is now seeded with the
section name and grown by the same tag segments the key prefix is grown by, so a
squashed field adds nothing and a nested one adds its own. A root section's
refusal now reads "node_base.Untagged has no mapstructure tag".

The section a refusal from the environment is recorded against was accepted and
then discarded, while the comment beside it said a refused key is attributable to
a registration. It is kept now, and named in the one error where attribution is
worth anything: a refusal whose key no section declares, which is the misspelling
case, where what an author needs is which registration to look at.

The note about a key sharing a table's name said nothing reports the collision.
Something does, one layer down: whatever installs a resolution refuses it, naming
both keys, because a source holds one value per path. So the failure is not
silent. It is late, arriving on a booting node rather than in the registration
test of the package that caused it, and the note now says that instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant