Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1da0d72
config: the first four sections enter the registry
bdchatham Aug 20, 2026
467ceec
config: four app sections enter the registry
bdchatham Aug 20, 2026
25fa5f9
config: the EVM sections enter the registry
bdchatham Aug 20, 2026
59a387a
config: the upstream server sections enter the registry
bdchatham Aug 20, 2026
f7dc6bb
config: say why a field excluded from configuration declares no key
bdchatham Aug 20, 2026
8466082
config: answer the store section per mode, and measure the divergences
bdchatham Aug 20, 2026
0e1d870
config: state each comment's own subject
bdchatham Aug 21, 2026
12fe6c0
config: name the untagged field, and say each thing once
bdchatham Aug 21, 2026
d87787d
config: answer the upstream sections per kind of node
bdchatham Aug 21, 2026
dc4a458
config: hand out values nothing else holds, and answer the EVM interf…
bdchatham Aug 21, 2026
db12097
config: refuse a mode nothing declares, and report a variable that di…
bdchatham Aug 21, 2026
eb53ea6
config: the contract says what the registry now does
bdchatham Aug 22, 2026
02878d0
config: drop a guard with no instance, and name the gap it leaves
bdchatham Aug 23, 2026
9b2d183
config: a declared value is what seid init writes, and the record mea…
bdchatham Aug 23, 2026
3f9d618
config: name which generator a declared value follows
bdchatham Aug 23, 2026
e244418
config: the archive retention departs on purpose, and says so
bdchatham Aug 24, 2026
7fc9154
config: name what these declared values are
bdchatham Aug 24, 2026
517f240
config: the wasm query limit is one statement, and it is the one a no…
bdchatham Aug 24, 2026
dd25b13
Merge branch 'plt-775-sections-1' into plt-775-boot
bdchatham Aug 24, 2026
13d1aab
Merge branch 'plt-775-sections-2' into plt-775-boot
bdchatham Aug 24, 2026
9040975
Merge branch 'plt-775-sections-3' into plt-775-boot
bdchatham Aug 24, 2026
534a847
Merge branch 'plt-775-sections-4' into plt-775-boot
bdchatham Aug 24, 2026
905df20
refactor(config): name the conditions a field tag has to pass
bdchatham Aug 24, 2026
1eab9bc
Merge branch 'plt-775-sections-1' into plt-775-tm-sections-1
bdchatham Aug 24, 2026
a8fe04f
feat(config): declare the peer-to-peer and remote procedure call sect…
bdchatham Aug 24, 2026
261a308
fix(config): no section declares the node's root directory
bdchatham Aug 24, 2026
d9b69bd
Merge remote-tracking branch 'origin/main' into plt-775-node-sections-1
bdchatham Aug 25, 2026
5d0f3c3
Merge branch 'main' into plt-775-node-sections-1
bdchatham Aug 25, 2026
8da6edd
docs(config): say what an exclusion claims that a dash tag does not
bdchatham Aug 25, 2026
1c57dd9
Merge remote-tracking branch 'origin/plt-775-node-sections-1' into pl…
bdchatham Aug 25, 2026
ca21fc9
fix(config): a resolution over a diminished key space is refused
bdchatham Aug 25, 2026
561d5db
fix(config): the peer-to-peer section states what it leaves out and why
bdchatham Aug 25, 2026
a4c11ef
fix(config): the nested-source refusal reads as a sentence
bdchatham Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/registry/detach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func TestAResolvedListIsTheCallersToWriteInto(t *testing.T) {
// Driven through a second resolution rather than by inspecting the variable, because what matters is not
// that a copy was taken but that the next caller is unaffected.
func TestSortingAResolvedNestedListLeavesTheSectionsOwnDefaultAlone(t *testing.T) {
registry.Reset()
type nested struct {
Deny map[string][]string `mapstructure:"deny"`
}
Expand Down
140 changes: 125 additions & 15 deletions config/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ type Section struct {
Prefix string
// Keys are the dotted paths this section declares, sorted.
Keys []string
// Excluded are dotted paths the struct carries that this section deliberately does not declare,
// sorted.
//
// Kept rather than discarded because both walks have to agree. The type walk decides what is declared
// and the value walk decides what is stated, and a path dropped from one and not the other makes a
// section that either declares a key nothing answers or answers a key it never declared.
Excluded []string

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] Excluded escapes the mutex uncopied. Sections() (registry.go:246) and Lookup() (registry.go:260) both copy Keys — with a comment saying it is "so a caller sorting or writing into Keys cannot reach the registry's own storage from outside the mutex" — but neither copies the new Excluded slice, so a caller that sorts or appends to it mutates registry state, racing every concurrent Resolve that reads s.Excluded in defaultValues. Copy it alongside Keys in both, and extend TestAReadKeySliceCannotReachTheRegistry to cover it.

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, and the consequence is sharper than a wrong exclusion. Excluded is read on resolve.go:176 on every Resolve, so a caller writing into it drops a path from one walk and not the other, and matchesDeclaration then fails for every mode for the life of the process.

Both accessors now go through one detached(), so a slice field added to Section later is copied without anyone remembering to do it twice. The regression test writes by index rather than appending, which matters: every section registered today has capacity equal to length, so an append reallocates and a test built on one would have passed while the defect was present.

// Defaults returns the section's default for a mode.
Defaults func(Mode) any
}
Expand Down Expand Up @@ -87,7 +94,31 @@ var (
// It never panics. A registration this package cannot use is recorded as a Defect and the
// section is not registered.
func RegisterSection(name string, prototype any, defaults func(Mode) any) {
record(name, name, prototype, defaults)
record(name, name, prototype, defaults, nil)
}

// RegisterSectionExcluding records a section, leaving out paths the struct carries that are not settings.
//
// Each excluding path is relative to the section, so "max-outbound-connections" rather than the dotted key
// it becomes. A path matching nothing the struct declares is refused, because an exclusion covering nothing
// reads as though it covered something.
//
// Two kinds of field earn this. 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 any default would be this package inventing one.
//
// A field can also carry "-" as its mapstructure name, and that says something different rather than the
// same thing in another place. The tag says the field is not configuration and no reader offers it. An
// exclusion says the opposite about the field and less about this package: the field is configuration, the
// reader that owns its file decodes it, and this key space does not offer it. Tagging one of these paths
// would take the setting away from that reader.
//
// The tag is a statement of intent and not a barrier. The decoder in use honours "-" when it renders a
// struct into a map and not when it decodes a map into a struct, where it takes the name literally, so a
// key spelled "-" reaches every field in the struct tagged that way at once. Nothing offers such a key and
// no operator writes one, which is why this is stated rather than guarded.
func RegisterSectionExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) {
record(name, name, prototype, defaults, excluding)
}

// RegisterRootKeys records a section whose keys sit at the root of the file, with no section of their own.
Expand All @@ -98,12 +129,30 @@ func RegisterSection(name string, prototype any, defaults func(Mode) any) {
// Some settings are node-wide and are written at the top of a file rather than inside a table. Giving them
// a section would rename them, and a renamed key is one an operator's existing file no longer reaches.
func RegisterRootKeys(name string, prototype any, defaults func(Mode) any) {
record(name, "", prototype, defaults)
record(name, "", prototype, defaults, nil)
}

// RegisterRootKeysExcluding records a root-key section, leaving out paths that are not settings.
//
// RegisterSectionExcluding says which fields earn an exclusion and how a path is spelled.
func RegisterRootKeysExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) {
record(name, "", prototype, defaults, excluding)
}

// record is the one path both registrations take.
func record(name, prefix string, prototype any, defaults func(Mode) any) {
func record(name, prefix string, prototype any, defaults func(Mode) any, excluding []string) {
keys, err := deriveKeys(name, prefix, prototype)
var excluded []string
if err == nil {
keys, excluded, err = withoutExcluded(prefix, keys, excluding)
}
// Refused after the exclusions are known, because deriveKeys can only see a struct that declares
// nothing and this catches the section whose every declared path was then excluded. Such a section
// answers no key while still taking its name, so the real registration under that name is later
// refused as a duplicate, and its defaults are still rendered on every resolution.
if err == nil && len(keys) == 0 {
err = fmt.Errorf("every path it declares is excluded (%v), so the section declares nothing", excluded)
}

mu.Lock()
defer mu.Unlock()
Expand All @@ -121,10 +170,53 @@ func record(name, prefix string, prototype any, defaults func(Mode) any) {
defects = append(defects, Defect{Section: name, Err: err})
return
}
sections[name] = Section{Name: name, Prefix: prefix, Keys: keys, Defaults: defaults}
sections[name] = Section{
Name: name, Prefix: prefix, Keys: keys, Excluded: excluded, Defaults: defaults,
}
}
}

// withoutExcluded splits derived paths into the ones a section declares and the ones it does not.
//
// An exclusion is spelled relative to the section, so this is where it becomes the dotted key both walks
// compare against. One matching no derived path is refused: the field it named was renamed or removed, and
// an exclusion for a field that is gone stops excluding anything while still reading as a deliberate
// omission.
func withoutExcluded(prefix string, derived, excluding []string) (keys, excluded []string, err error) {
if len(excluding) == 0 {
return derived, nil, nil
}
drop := make(map[string]bool, len(excluding))
for _, rel := range excluding {
key := rel
if prefix != "" {
key = prefix + "." + rel
}
if drop[key] {
return nil, nil, fmt.Errorf("%s is excluded twice, and the second one covers nothing", key)
}
drop[key] = true
}
for _, key := range derived {
if drop[key] {
excluded = append(excluded, key)
delete(drop, key)
continue
}
keys = append(keys, key)
}
if len(drop) > 0 {
missing := make([]string, 0, len(drop))
for key := range drop {
missing = append(missing, key)
}
sort.Strings(missing)
return nil, nil, fmt.Errorf("%v is excluded and the struct declares no such key, so the "+
"exclusion covers nothing", missing)
}
return keys, excluded, 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] A section whose every derived path is excluded registers with Keys == nil. deriveKeys refuses "declares no keys", but that check runs before exclusion, and afterwards envNamesAreDistinct(nil) and matchesDeclaration(nil, {}) both pass — so the registry accepts a section that declares nothing, which is the exact registration it refuses one line earlier in the type walk. Re-checking for an empty keys here would close it in the same place the exclusion is applied.

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, refused rather than re-checked. Such a section answers no key while taking its name, so the real registration under that name is then refused as a duplicate, and its defaults are still rendered on every resolution. The refusal now sits in record after withoutExcluded, beside the sibling guards that already work that way.

}

Comment thread
cursor[bot] marked this conversation as resolved.
// envNamesAreDistinct refuses keys that share one environment spelling. Callers hold mu.
//
// Dots and hyphens both become underscores, so two keys differing only in that punctuation answer to
Expand Down Expand Up @@ -160,14 +252,24 @@ func envNamesAreDistinct(adding []string) error {
return nil
}

// detached returns a copy of the section that shares no storage with the registry's own.
//
// Every path out of the mutex goes through this, so a slice field added to Section later is copied
// without anyone having to remember to copy it at each accessor. A caller that sorts or writes into
// what it was handed reaches its own storage, and both walks keep reading what registration decided.
func (s Section) detached() Section {
s.Keys = append([]string(nil), s.Keys...)
s.Excluded = append([]string(nil), s.Excluded...)
return s
}

// Sections returns every registered section, sorted by name.
func Sections() []Section {
mu.RLock()
defer mu.RUnlock()
out := make([]Section, 0, len(sections))
for _, s := range sections {
s.Keys = append([]string(nil), s.Keys...)
out = append(out, s)
out = append(out, s.detached())
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
Expand All @@ -178,10 +280,7 @@ func Lookup(name string) (Section, bool) {
mu.RLock()
defer mu.RUnlock()
s, ok := sections[name]
// Copied, so a caller sorting or writing into Keys cannot reach the registry's own storage from
// outside the mutex. Defects copies for the same reason.
s.Keys = append([]string(nil), s.Keys...)
return s, ok
return s.detached(), ok
}

// Defects returns every registration this package could not use.
Expand Down Expand Up @@ -367,7 +466,10 @@ func tagOf(f reflect.StructField, label string) (name string, squash, skip bool,
"unreachable through their tags", label, f.Name)
}

name, squash = parseTag(tag)
name, squash, remain := parseTag(tag)
if remain {
return "", false, true, nil
}
if squash {
if name != "" {
return "", false, false, fmt.Errorf("%s.%s is squashed and also names %q; one or the other",
Expand All @@ -394,15 +496,23 @@ func tagOf(f reflect.StructField, label string) (name string, squash, skip bool,
return name, false, false, nil
}

// parseTag splits a mapstructure tag into the name it gives a field and whether it squashes.
func parseTag(tag string) (name string, squash bool) {
// parseTag splits a mapstructure tag into the name it gives a field and the options that change what the
// field is.
//
// A squashed field contributes its own fields at this level rather than a segment of its own. A remaining
// field is where the decode puts what it matched no field for, so it declares no key: what lands in it is
// what an operator misspelled, and giving it a key would offer the collector itself as a setting.
func parseTag(tag string) (name string, squash, remain bool) {
parts := strings.Split(tag, ",")
for _, opt := range parts[1:] {
if opt == "squash" {
switch opt {
case "squash":
squash = true
case "remain":
remain = true
}
}
return parts[0], squash
return parts[0], squash, remain
}

// assignedOutsideConfiguration reports whether a tag excludes its field from configuration.
Expand Down
53 changes: 53 additions & 0 deletions config/registry/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ type Resolved struct {
// supply. LookupEnv is a function rather than a map because an environment cannot be enumerated for a
// prefix, since a variable is only findable if you already know its name.
type Sources struct {
// File is one flat map whose keys are whole dotted paths, matched without regard to case.
//
// Flat rather than nested, because that is the shape the other two sources carry: a flag name and
// an environment variable are each one string naming one key. A decoded configuration file is the
// wrong shape, since a table there is a nested map and every key an operator wrote sits a level
// below where anything matches it. Resolve refuses that shape rather than resolving past it.
File map[string]any
LookupEnv func(string) (string, bool)
Flags map[string]any
Expand Down Expand Up @@ -90,6 +96,17 @@ func Resolve(mode Mode, from Sources) (Resolved, error) {
mode, Modes())
}

// Refused before anything is resolved, because a registration this package could not use is a
// section missing from the key space, and every key it declared then reads as one no section
// declares. An operator's written value for such a key is reported as unknown rather than applied,
// which is the hole this function promises never to hand out. Two sections colliding is the case
// that matters: the loser is dropped whole and which one loses follows package initialisation
// order, so the same binary can answer differently for reasons no caller can see.
if bad := Defects(); len(bad) > 0 {
return out, fmt.Errorf("the registry could not use %d registration(s), so the key space is "+
"incomplete: %v", len(bad), bad)
}

// One snapshot, read once and passed everywhere below. Every part of the answer has to describe the
// same registry: asking again leaves a window a concurrent registration fits through, and a section
// arriving in that window is declared by one part of the answer and not by another.
Expand All @@ -99,6 +116,9 @@ func Resolve(mode Mode, from Sources) (Resolved, error) {
return out, err
}
declared := declaredKeys(registered)
if err := refuseANestedFile(from.File, declared); err != nil {
return out, err
}
undeliverable := keysNoVariableCanCarry(defaults)

out.Values = make(map[string]any, len(declared))
Expand Down Expand Up @@ -171,6 +191,11 @@ func defaultValues(mode Mode, registered []Section) (map[string]any, error) {
if err != nil {
return out, fmt.Errorf("section %q default for mode %q: %w", s.Name, mode, err)
}
// The same paths the declaration left out. Both walks read the one struct, so a path dropped from
// the declared side and kept here would be a value under a key nothing declares.
for _, key := range s.Excluded {
delete(values, key)
}
if err := matchesDeclaration(s.Keys, values); err != nil {
return out, fmt.Errorf("section %q default for mode %q: %w", s.Name, mode, err)
}
Expand Down Expand Up @@ -451,6 +476,34 @@ func envValues(declared map[string]bool, undeliverable map[string]string,
//
// A source enumerates lower-cased while a file may not be written that way, and a key that differed
// only in case would resolve as unknown while the operator's value went nowhere.
// refuseANestedFile refuses a file source whose tables were left nested.
//
// The shape a configuration reader hands back, and it resolves to pure defaults without complaint: every
// key an operator wrote sits one level below where a dotted path matches it, so nothing is applied, and
// the table's own name is the one thing reported, as a key no section declares. A caller that then
// installs what it resolved writes the whole declared set over the file it was trying to read.
//
// Detected by a table holding a declared key, which is that mistake and nothing else. A declared key
// whose own field is a map is left alone, since the value an operator writes for it is a table.
func refuseANestedFile(values map[string]any, declared map[string]bool) error {
for key, v := range values {
if _, isTable := v.(map[string]any); !isTable {
continue
}
lower := strings.ToLower(key)
if declared[lower] {
continue
}
for d := range declared {
if strings.HasPrefix(d, lower+".") {
return fmt.Errorf("the file source holds a table at %q carrying %q, so it is nested "+
"where this reads one flat map of whole dotted keys", key, d)
}
}
}
return nil
}

func fileValues(values map[string]any) map[string]any {
if values == nil {
return nil
Expand Down
Loading
Loading