Skip to content

[ConfigManager] Config Registry - #3943

Merged
bdchatham merged 15 commits into
mainfrom
plt-775-registry
Aug 18, 2026
Merged

[ConfigManager] Config Registry #3943
bdchatham merged 15 commits into
mainfrom
plt-775-registry

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

A configuration key today needs three things written separately, the reader's lookup, a flag binding, an environment spelling, with nothing tying them together, so a rename moves one and leaves the others. Several such mismatches are already pinned in testutil/configtest (#3816, #3837, #3851). A key now enters once, as a field on its owning package's section struct, and all three come from that field's mapstructure tag.

Start with config/registry/doc.go — the problem, the declaration contract, what the package deliberately is not, and how to add a section.

Scope

Resolve answers for declared keys only, so it serves a diagnostic or an authoring check, not the boot. A running node reads a source carrying every key whether a section declares it or not. Delivery, the file format, and per-section validation are later slices.

Verified

build, vet, gofmt -s, goimports, golangci-lint clean. 34 tests, 104 assertions, 100% of statements with no unreached block, under -race.

Resolve reads the registry once, so the defaults and the declared set describe one registry. That property has no test: from outside the package, a section arriving during the call cannot be told from one arriving after it.

@github-actions

github-actions Bot commented Aug 17, 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 18, 2026, 6:54 PM

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.63%. Comparing base (e63fe54) to head (db66208).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3943      +/-   ##
==========================================
- Coverage   59.60%   58.63%   -0.97%     
==========================================
  Files        2329     2233      -96     
  Lines      199860   188907   -10953     
==========================================
- Hits       119117   110757    -8360     
+ Misses      69339    67638    -1701     
+ Partials    11404    10512     -892     
Flag Coverage Δ
sei-chain-pr 100.00% <100.00%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

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

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

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

@bdchatham
bdchatham force-pushed the plt-775-registry branch 2 times, most recently from 6824cf0 to c8d3161 Compare August 17, 2026 22:15
@bdchatham bdchatham changed the title [ConfigManager] config/registry: one declaration point for a stable configuration key [ConfigManager] Config registry Aug 17, 2026
Base automatically changed from configtest-drop-coverage-record to main August 18, 2026 15:37
@bdchatham bdchatham closed this Aug 18, 2026
@bdchatham bdchatham reopened this Aug 18, 2026
bdchatham and others added 6 commits August 18, 2026 08:49
A key enters here once, as a field on its owning package's section struct, registered with
a baseline that may vary by node mode. The dotted key identity, the canonical environment
spelling, the schema fingerprint and the read site all derive from that one registration, so
nobody hand-writes a flag, an environment binding or a cast-heavy reader per key. Today those
three derivations are written out per key at three separate call sites, which is why a rename
can move the reader and leave the flag behind.

Baselines are not state. They live in the binary, may change between releases, and never
mutate a configuration file or require a migration. A written value is a commitment the system
never rewrites; an absent key tracks whatever baseline the running binary carries.

Resolve reduces a set of named layers to one value per declared key, in the order Precedence
declares, and records which layer each value came from. That last part is the reason it exists
rather than a map merge: the legacy path combines its layers inside one viper before anything
observes them, so a value's origin is unrecoverable and an operator whose file says one thing
and whose node does another has no way to find out why.

It answers for declared keys only, so it serves a diagnostic or an authoring check rather than
the boot. Nothing reads it yet. This is the declaration point on its own, with no section
registered and no reader migrated, so it changes how no node runs.

Registering never panics. A registration this package cannot use is recorded as a Defect and
the section is not registered, because a panic during the package initialisation of something
every feature imports would take down every seid invocation including --help, and would turn a
compile-time-fixable mistake into a fleet-wide incident.

spec_test.go asserts each rule as its own test, named for the property it holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
doc.go states the rules for someone reading the package's documentation. This says the
things a reader outside the design discussions needs and a godoc is the wrong place for:
the problem the package exists for, what it deliberately does not do, and how to add a
section.

The problem stated once, so nobody has to reconstruct it. A key needs its reader's lookup,
its flag binding and its environment spelling written down, they are written separately
today, and nothing ties them together. A rename moves one and leaves the others, which is
how a key an operator sets ends up reaching nothing.

The four things it says the package is not are the ones a reviewer would otherwise have to
infer from absence. It is not the boot, not a file format, not a validator, and not wired to
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolve's documentation says every declared key resolves, because the baseline is a layer
like any other. That was false for any section with a pointer field. The type walk unwraps a
pointer to derive its keys, and it must, or an optional subtree would declare nothing. The
value walk skips a nil one, and it must, or a section would claim defaults it does not have.
Each choice is right alone. Together they produce a declared key with no resolution.

The hole reads as correct, which is what makes it worth refusing rather than documenting.
Overrides iterates the resolutions and simply never reports the key, and From answers false,
so a diagnostic that renders Keys and calls From prints nothing for it and looks right.

Filling the hole is not honest either way. A zero value states a judgement the binary never
made, which is the failure TestEveryModeHasABaseline already exists to prevent. Dropping the
key from the declared set makes an operator's written value land in Unknown, where it cannot
be told from a typo, and makes the declared set vary by mode, which would make the
fingerprint vary by mode.

So a baseline that does not state a value for every key its section declares is refused, at
the one function every resolution passes through. A section states a value for each key it
declares, or declares against a struct without the optional field. Both are explicit.

The same guard closes the general case it is an instance of. Nothing held the two walks to
one type: the prototype's type derives the keys while whatever Defaults returns supplies the
values, so the two could traverse different structs and every declared key would silently
carry nothing.

That divergence could also panic. A baseline whose type squashes a non-struct reached
reflect.NumField on a string, in a package whose stated posture is that a bad registration
never panics. The value walk now carries the guard the type walk already had. Reaching it
takes a valid prototype and a divergent baseline, since a bad prototype is refused at
registration and the section is never registered at all.

Both tests fail without their guard, checked one at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fingerprint hashed every registration so a key added, renamed or retyped moved the hash, and
its comment said CI compares it against a recorded one and fails until the bump and its
migration land together. No CI does that. Nothing in .github or the Makefile mentions a
fingerprint, and its only caller anywhere was one assertion seven changes further along.

So it was a primitive built for a gate nobody has written, described by a comment asserting
that gate exists. It comes back with the check that uses it, where a reviewer can judge the
two together instead of taking the hash on the promise of a consumer.

EnvPrefix goes unexported in the same pass. It had no reader outside this package, and the
one test reading it asserted EnvName starts with the constant EnvName itself uses, which
holds for any value. It now asserts the namespace it actually means.

Four comments claimed the fingerprint derives from a registration alongside the key identity
and the environment spelling. They name the read site instead, which is what remains true.

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

Coverage was 83.8%, and the number was the least of it. Every test registered a flat struct of
scalars, which is the one shape no upstream configuration struct is. isLeaf's true branch was
never taken and the value walk's nested-struct and live-pointer recursion were both dead, so
the walks were only ever exercised on the case they cannot get wrong.

A section carries a nested struct for a sub-table, an embedded base it squashes so the base
adds no segment, a duration that must stay one value rather than becoming a group of keys, and
sometimes a pointer that is set. Each takes a different branch, and both walks have to agree on
all of them or a key derives with nothing behind it. One test now drives all four and checks
the resolved values, not only the key names.

The refusals were the other gap. The package's stated posture is that it refuses rather than
guesses, and only the untagged field was covered. A refusal nothing exercises is a refusal that
can stop working, so each is now driven: a squashed field that also names a segment, an empty
name, a dash name, an upper-case key, a squashed scalar, a struct declaring nothing, an empty
or upper-case section name, a missing struct, a non-struct prototype, a missing baseline, a
section registered twice, two layers naming one source, a baseline that is nil or a scalar or a
nil pointer, and a bad tag nested one level down and inside a squashed base. 98.2%.

Writing them turned up two things about the code. time.Duration is an int64, so the walk takes
the leaf path on its kind alone and the "time.Duration" entry in isLeaf never matches; the test
that covers the reachable half says so. And an unexported embed is skipped before its tag is
read, which masked three of these refusals until the embedded types were exported. Both are
recorded where a reader meets them.

The README claimed a rename "moves all of them together or fails to compile". It does not. The
tag is a string literal, so a rename is a rename of text; what changes is that there is one
occurrence instead of three, and the test holding the derived names against the reader is what
turns a disagreement into a failure. Said plainly now, because a claim of compiler enforcement
is the kind a reviewer checks first.

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

The package used two. Everything a caller or an operator touches said default: Precedence's
first entry, the Source that Resolution.From reports, the Defaults field, and the test in
Overrides. Everything explanatory said baseline. One comment used both in a single sentence,
"Defaults returns the section's baseline for a mode", which is the clearest sign the second
word was carrying nothing the first did not.

Collapsed onto default, because that is the word already in the surface. An operator reading
which layer won sees "default"; a section author writes a Defaults function. Teaching them a
second word for the same thing in the prose only asks them to map between the two.

Two identifiers moved to make room. The Source-name constant is defaultSource, since it names
a Layer.Source and now says so, which frees defaultLayer for the function that renders one.

Nothing about the claim changes. Defaults are still not state: they live in the binary, may
change between releases, and never mutate a file or require a migration. Saying it with the
word people already have the wrong intuition about is better than inventing a second word to
carry the correction.

Coverage holds at 98.2%, and the suite passes under -race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham marked this pull request as ready for review August 18, 2026 15:53
@cursor

cursor Bot commented Aug 18, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Additive library with no production wiring; node boot and readers are unchanged. Risk is limited to future adoption mistakes (wrong tags/defaults), which the defect and spec tests are meant to catch early.

Overview
Introduces config/registry, a new package where each config section registers once via RegisterSection on the reader’s struct and per-node-mode defaults. Dotted keys, SEID_* env names, and default values are derived from mapstructure tags instead of being hand-written in three places.

Resolve merges sources in fixed order (defaults → file → environment → flags), returns Overrides and Unknown keys for diagnostics, and errors if any declared key lacks a default. Invalid registrations are recorded as Defect (no init panics). The package is documented as not wired into node boot yet—foundation for ConfigManager follow-up.

A large spec_test suite exercises derivation against live keys (e.g. giga executor), legacy spellings, env collisions, concurrency, and resolution edge cases.

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

@bdchatham
bdchatham requested a review from yzang2019 August 18, 2026 15:57
Comment thread config/registry/registry.go
Comment thread config/registry/registry.go

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A self-contained, well-documented new config/registry package with no production wiring yet, so nothing here can break a running node. The findings are gaps between what the package documents/promises and what it enforces — an unimplemented env-collision check, a walk that can derive duplicate or silently-empty key paths, a doc reference to a function that does not exist, and one test that can never fail.

Findings: 0 blocking | 8 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Precedence is an exported mutable package-level slice (registry.go:34), so any importer can reorder or truncate the declared precedence at runtime — which is exactly the "emergent rather than declared order" the package exists to remove. Consider func Precedence() []string returning a fresh slice, matching the shape already used by Modes().
  • Reset() is exported from production code and lives in registry.go rather than a test-only file. Since the tests are in an external registry_test package it does need to be exported, but a separate export_test.go-style seam or a registrytest helper would keep "clear the global registry" out of the API every feature package imports.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread config/registry/registry.go
Comment thread config/registry/registry.go
Comment thread config/registry/spec_test.go Outdated
Comment thread config/registry/registry.go
Comment thread config/registry/registry.go Outdated
Comment thread config/registry/registry.go
@bdchatham bdchatham changed the title [ConfigManager] Config registry [ConfigManager] Config Registry Aug 18, 2026
Comment thread config/registry/registry.go Outdated
//
// It never panics. A registration this package cannot use is recorded as a Defect and the
// section is not registered.
func RegisterSection(name string, proto any, defaults func(Mode) any) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was wondering what does proto name stands for in this context?

@bdchatham bdchatham Aug 18, 2026

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.

I've updated this sinceproto is a bit overloaded and could read as protobuf everywhere else in this tree, and the argument is a prototype struct. Renamed to prototype, and RegisterSection's godoc now says what it is for: read for its fields and their tags, never for its values, which is why it is the reader's own struct rather than a copy. A second struct would be a second statement of the same key set, and the two would disagree the first time somebody edited one.

Leaving this thread for you to close, since it was a question rather than a defect.

The package refused a key it could not derive and admitted several it could
derive but nothing could reach. Each of these registered cleanly and left a
setting an operator writes into nothing:

- a struct that contains itself, which overflowed the stack during package
  initialisation rather than recording a defect, since a stack overflow cannot
  be recovered into one
- two fields declaring one path, where one of them is unreachable and which one
  is not observable
- a section name or a tag carrying a dot or a space, which claims a subtree the
  struct does not have
- a struct field that declares no key, whether an empty struct, one whose fields
  are all unexported, or a defined type over an opaque leaf
- an unexported field carrying a tag, which silently dropped an embedded type's
  whole subtree through ,squash
- two keys differing only between a dot and a hyphen, which answer to one
  environment variable, as EnvName's own documentation already claimed was
  checked

Resolve took two registry snapshots, one to render the defaults and one for the
set every layer's keys are checked against, so a section registering between
them was declared with no default rendered. Both now come from one snapshot.

Lookup and Sections handed out the registry's own key slice, which a caller
could sort or write into from outside the mutex. Both copy, as Defects already
did.

Precedence was an exported mutable slice, so one importer reordering it at init
would change every other importer's resolved values. It is now a function
returning a copy.

Removes Resolution.Key, which duplicated the map key it is stored under and
nothing read, and the "time.Duration" entry in isLeaf, which the walk cannot
reach because it asks isLeaf only after finding a struct.

Every guard is verified by removing it and watching the covering test fail.
The package is at 100% of statements with no unreached block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
proto reads as protobuf in this tree, where the argument is a struct read for
its fields and tags and never for its values. RegisterSection's godoc now says
that, since it is what a reader has to know to pass the right struct.

TestEnvSpellingCollisionsAreDetectable said a collision check was owed. It
exists, so the comment points at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +20 to +23
ModeValidator Mode = "validator"
ModeFull Mode = "full"
ModeSeed Mode = "seed"
ModeArchive Mode = "archive"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is nice

@bdchatham
bdchatham added this pull request to the merge queue Aug 18, 2026

@masih masih left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Questions please.

Comment thread config/registry/README.md Outdated
Comment thread config/registry/registry.go
Comment thread config/registry/registry.go Outdated
// open carries the struct types on the current path, so a self-referential one is refused rather than
// recursed into. A stack overflow cannot be recovered into a Defect, so this is the one refusal that
// has to happen before the recursion rather than after it.
func walk(t reflect.Type, prefix string, keys *[]string, open map[reflect.Type]bool) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unfortunate to use reflection; can we design this such that all the config is verified at compile time?

I am curious to understand why reflection at all.

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.

Yeah, not ideal but it's something we can continue to consolidate complexity out of once we harmonize where we are sourcing configuration from. The downstream readers are the challenge with this, since they ask for a specific dotted config string like viper.GetBool("giga_executor.occ_enabled"). My thinking is we take these improvements in bite-sized chunks. This first step is bridging configuration to the registry pattern and essentially wrapping the app.toml and config.toml plumbing in a single sei.toml source

After this lands, I see no reason we can't go to the consumer code paths and move them off the dotted lookup onto the struct field it already decodes into, then remove the lookups all together once there are no remaining read edge cases. The key names themselves mostly stay as they are, since renaming one breaks a file an operator already wrote

Comment thread config/registry/resolve.go Outdated
@masih
masih removed this pull request from the merge queue due to a manual request Aug 18, 2026
bdchatham and others added 3 commits August 18, 2026 10:26
The README and doc.go both described the package, so a rule could be edited in
one and left in the other. doc.go now carries all of it, using the headings,
lists and code blocks a Go doc comment supports, so it renders as one page and
gofmt keeps it canonical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A layer named its source with a string and the order it won in came from an
exported slice, so an importer could reorder the precedence at init and change
every other importer's resolved values. Source is now a typed constant whose
declaration order is the precedence, which Resolve reads directly.

sourceNames keys each name to the constant it belongs to, so the set and its
names are one statement and cannot be paired wrongly. Sources derives from it.

A Source is an int, so a value no constant names is still representable and
Resolve refuses it rather than skipping that layer in silence. Removes the
Precedence accessor and the known lookup it fed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing needs to know which source supplied a key. Every consumer asks the
narrower question, whether a key carries a value somebody chose or one the
binary decided, and Resolved answers that with Overrides.

Resolve now takes the sources as a struct of named fields instead of a variadic
list of self-describing layers. The precedence is stated once, in the loop that
applies them. Named fields rather than positional parameters because File and
Flags are the same type, so passed positionally, swapping the two compiles and
silently inverts the precedence for every key both supply.

Three refusals go with the layer type, each unrepresentable now rather than
checked: a layer naming a source that has no priority, a layer naming the
reserved default, and two layers naming one source. FileLayer and EnvLayer are
internal, since the normalisation a file needs and the declared-key direction the
environment needs are both details of resolving rather than choices a caller
makes.

Removes Layer, Resolution, Source and its four constants, Sources, String and
the name table. The exported surface is thirteen symbols.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread config/registry/resolve.go
bdchatham and others added 2 commits August 18, 2026 11:14
Resolve derived its declared set from one snapshot and envValues read the
registry again for the same thing. A section registering between the two is
declared by the second read and not the first, so its environment variable is
read and then reported as a key no section declares. An operator whose variable
is real would be told it matches nothing.

envValues now takes the declared set rather than reading it, so one Sections
call answers for the whole resolution.

The failure has an external symptom, unlike the earlier window, so it has a
test: a key reported as undeclared that the registry declares. The trigger is
deterministic rather than raced, because Resolve calls each section's Defaults
between the two reads and Defaults is caller-supplied. A goroutine does not
reliably land in that window, and a concurrent version of the test passed with
the defect present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham requested a review from masih August 18, 2026 18:15

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bd827db. Configure here.

Comment thread config/registry/registry.go
bdchatham and others added 2 commits August 18, 2026 11:26
…time

"Today they are written separately" and "the binder the node uses today" both
date the documentation. Neither needs to: what makes the three statements drift
is that a key does not register here, and what falls back to a field name is
mapstructure.

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

A section name was checked for a dot and a field tag for a dot or a space, so a
section named "a b" registered cleanly and declared "a b.a", whose environment
spelling is SEID_A B_A. That variable cannot be exported, and the key cannot be
written in a file unquoted.

The two checks were the same rule stated twice, which is how they drifted, so
unaddressableChar now holds it for both. Each caller keeps its own consequence,
since a dot in a section name shadows another section's subtree while a dot in a
tag claims a subtree the struct does not have.

Removing the space from the shared rule now fails both the section and the tag
case, where before it could only fail one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham enabled auto-merge August 18, 2026 18:51
@bdchatham
bdchatham added this pull request to the merge queue Aug 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 18, 2026
@bdchatham
bdchatham added this pull request to the merge queue Aug 18, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 18, 2026
@bdchatham
bdchatham added this pull request to the merge queue Aug 18, 2026
@bdchatham
bdchatham removed this pull request from the merge queue due to a manual request Aug 18, 2026
@bdchatham
bdchatham added this pull request to the merge queue Aug 18, 2026
@bdchatham
bdchatham removed this pull request from the merge queue due to a manual request Aug 18, 2026
@bdchatham
bdchatham added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit d45194a Aug 18, 2026
78 of 79 checks passed
@bdchatham
bdchatham deleted the plt-775-registry branch August 18, 2026 21:09
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.

4 participants