Config.Int, Config.Int64, Config.Float64, and Config.Bool (config/getters.go) return the caller's default — or the type's zero when no default is passed — for a key that is present but empty. So CUSTOM_BATCHSIZE= plus cfg.Int("custom.batchsize") yields a silent 0, which is the same silent-zero this repo just closed at the decode layer in ADR-074.
The information needed to do better already exists one layer down and is thrown away: rawValue reports that the key exists, and toInt64 / toBool (config/converters.go) return errEmptyString for the empty case — the getter discards that error and falls back. So the converters layer already distinguishes "absent, use the default" from "present but unusable"; only the public getters collapse the two.
Why it matters
The framework now answers this input three different ways depending on which seam a consumer reads through:
| seam |
FOO= for a numeric key |
config.Load into framework structs |
fails startup naming the key (ADR-074) |
Config.Unmarshal into a consumer struct |
fails, same rule |
Config.RequiredInt |
returns an error |
Config.InjectInto |
returns an error |
Config.Int / Bool / Int64 / Float64 |
silently returns the default |
The last row is the odd one out, and it is a documented public accessor — the seam a consumer reaches for when they do not want a struct.
Suggested shape
Have the getters separate absence from a present-but-unparseable value. Options worth weighing: return the zero value plus a logged WARN naming the key (cheapest, still silent to code), or add error-returning siblings and leave the current ones as the lenient path, or make them fail loudly and treat it as a behaviour change with an ADR + migrations atom. The choice depends on how much of the consumer surface is expected to keep the lenient reading; the current state is not a choice, it is an oversight inherited from before the delivered-empty rule existed.
Related: #1110 (the *bool half of the same class at the decode layer).
Config.Int,Config.Int64,Config.Float64, andConfig.Bool(config/getters.go) return the caller's default — or the type's zero when no default is passed — for a key that is present but empty. SoCUSTOM_BATCHSIZE=pluscfg.Int("custom.batchsize")yields a silent0, which is the same silent-zero this repo just closed at the decode layer in ADR-074.The information needed to do better already exists one layer down and is thrown away:
rawValuereports that the key exists, andtoInt64/toBool(config/converters.go) returnerrEmptyStringfor the empty case — the getter discards that error and falls back. So the converters layer already distinguishes "absent, use the default" from "present but unusable"; only the public getters collapse the two.Why it matters
The framework now answers this input three different ways depending on which seam a consumer reads through:
FOO=for a numeric keyconfig.Loadinto framework structsConfig.Unmarshalinto a consumer structConfig.RequiredIntConfig.InjectIntoConfig.Int/Bool/Int64/Float64The last row is the odd one out, and it is a documented public accessor — the seam a consumer reaches for when they do not want a struct.
Suggested shape
Have the getters separate absence from a present-but-unparseable value. Options worth weighing: return the zero value plus a logged WARN naming the key (cheapest, still silent to code), or add error-returning siblings and leave the current ones as the lenient path, or make them fail loudly and treat it as a behaviour change with an ADR + migrations atom. The choice depends on how much of the consumer surface is expected to keep the lenient reading; the current state is not a choice, it is an oversight inherited from before the delivered-empty rule existed.
Related: #1110 (the
*boolhalf of the same class at the decode layer).