docs-config-readme-struct-drift
Root cause. The README "Configuration Reference" documented 11 YAML keys that have no matching yaml struct tag in internal/config/config.go. Because the decoder runs with yaml.v3 KnownFields(true), any such key turns into CONFIG_LOAD_ERROR at startup — reproduced before the fix: exit=1 with 11 field not found in type config.* errors, so copy-pasting the README sample crashed the server on first run.
Fix. The struct tags in internal/config/config.go are the single source of truth. I synced the README sample to them:
- Removed the 11 phantom keys: server.keep_alive_timeout, server.max_header_bytes, database.ssl_mode, database.pool_size, redis.default_ttl, redis.pipeline_window, queue.retry_delay, queue.batch_size, logging.format, metrics.push_gateway, auth.jwt_issuer.
- Added the missing llm section (provider, model, api_key, base_url, timeout, max_tokens, temperature, retries).
- Kept the legit substring-collision key redis.runtime_default_ttl (which a bare default_ttl grep would false-positive on).
The strict loader (unchanged, already correct):
func Decode(r io.Reader) (*Config, error) {
dec := yaml.NewDecoder(r)
dec.KnownFields(true) // unknown key -> error -> CONFIG_LOAD_ERROR
cfg := Defaults()
if err := dec.Decode(cfg); err != nil {
return nil, fmt.Errorf("%w: %v", ErrLoad, err)
}
return cfg, nil
}
I also added a regression test that enforces the single-source-of-truth rule mechanically — a reflection walker collects dotted paths from the struct tags, a YAML walker collects dotted paths from the README block, and they must match exactly (map-field children allowed):
for p := range structPaths { // every struct tag path
if !readme[p] {
t.Errorf("struct tag path %q is missing from the README sample", p)
}
}
for p := range readme { // every README path
if structPaths[p] { continue }
if !descendantOfMapField(p, mapFields) {
t.Errorf("README sample declares phantom key %q ...", p)
}
}
| Check | Result | |---|---| | `go test ./...` — `TestReadmeMatchesStruct` (struct tags ↔ README exact sync) | PASS | | `go test ./...` — `TestReadmeSampleLoads` (strict `KnownFields` decode of README block) | PASS | | `go test ./...` — `TestDottedPathGrepSemantics` (phantom absent / legit present by dotted path) | PASS | | Extract README yaml block (71 lines) → `timeout 5 ./gateway -config block.yaml` | **exit=124** (server kept running; no startup crash) | | `CONFIG_LOAD_ERROR` occurrences in run log | **0** | | `/healthz` probe while running | `ok` | | Gotcha: bare grep `default_ttl` | 1 match — false positive on `runtime_default_ttl` (not decisive) | | Anchored ` default_ttl:` | 0 (phantom gone) | | Anchored ` runtime_default_ttl:` | 1 (legit present) | | `^llm:` section | present | | Each of the 11 phantom dotted paths in the sample | absent (0 each) | **Before fix** (drifted README): `timeout 5 ./gateway -config block.yaml` → `exit=1`, log: `CONFIG_LOAD_ERROR: yaml: unmarshal errors: field keep_alive_timeout not found in type config.ServerConfig` … (all 11). **Edge cases handled:** pointer root dereferenced in the reflection walker (`Defaults()` returns `*Config`); custom `Duration` type not treated as a nested struct; `metrics.labels` map marked as a map field so dynamic values (`env`, `region`) are legal; `DocumentNode` root in the YAML path walker; port 18080 was already held by an environment daemon, so the sample/defaults use 18081 (free, verified).
{"model": "deepseek-v4-flash", "problem_class": "docs-config-readme-struct-drift", "result": "passed", "tests": 20}