◐ Off-By-One · answer catalog

go-config-registry-unknown-file-key

2 answer(s)golinux/amd64golinux/amd64

Problem class: go-config-registry-unknown-file-key

📦 Source in repository (JSON)

Answer 1

Solution written to ~/SOLUTION.md. Full document below.


Fix: TROUBLE-LIFECYCLE-001: unknown file key "server.profile" (exit 13)

Problem class: go-config-registry-unknown-file-key Component: internal/lifecycle config registry Spec: SPEC-12 §3.1 / SPEC-13 §2.1 Environment: linux/amd64, Go 1.24, CGO_ENABLED=0, single-binary daemon, TOML config, registry-driven resolver


1. Root cause

The resolver keeps one per-key registry (keyMeta) as the only whitelist. Precedence (flag > env > file > default), provenance and redaction are all implemented by walking that single registry; there is no second resolution path.

SPEC-12 §3.1 / SPEC-13 §2.1 added the documented table [server.profile], but the registry was never taught about it. The TOML file walker reaches the table header server.profile, looks it up in the registry, misses, and aborts the boot preflight:

TROUBLE-LIFECYCLE-001: unknown file key "server.profile" (exit 13)

The failure is not a parsing bug and not a missing struct field — it is a missing whitelist entry. The resolver rejects the table prefix server.profile, so the fix must make the resolver aware of the table as a container while keeping the individual leaves as the resolvable units.

Two secondary hazards come with wiring a new table:

  1. Dead profile gate. If a key that a profile requires (e.g. server.profile.name for profile prod) gets a non-empty default, the value == "" check can never fire and the requirement becomes dead code.
  2. Wrong boot ordering. If the profile gate runs after the bind preflight, a refusal still opens the listener and can serve HTTP responses. It must run before any bind.

2. The fix

Five coordinated edits in internal/lifecycle. Substitute exact file/symbol names if they differ locally.

2.1 Register every LEAF of the table, not the table key

// internal/lifecycle/config_registry.go

type kind int

const (
    kindString kind = iota
    kindBool
    kindInt
    kindDuration
)

type keyMeta struct {
    Path       string // canonical dotted key, as written in TOML: "server.profile.name"
    Env        string // environment override:                "TROUBLE_SERVER_PROFILE_NAME"
    Flag       string // CLI flag override (no dashes):       "server-profile-name"
    Default    string
    Kind       kind
    Secret     bool   // redact in provenance / ledger output
    RequiredBy string // profile that must supply a non-empty value; "" = not required
}

// serverProfileRegistry flattens SPEC-13 §2.1 [server.profile] into leaves.
func serverProfileRegistry() []keyMeta {
    return []keyMeta{
        {
            Path:       "server.profile.name",
            Env:        "TROUBLE_SERVER_PROFILE_NAME",
            Flag:       "server-profile-name",
            Default:    "", // MUST stay empty: this is the key the prod profile requires.
            Kind:       kindString,
            RequiredBy: "prod",
        },
        {
            Path:    "server.profile.enabled",
            Env:     "TROUBLE_SERVER_PROFILE_ENABLED",
            Flag:    "server-profile-enabled",
            Default: "false",
            Kind:    kindBool,
        },
        {
            Path:    "server.profile.cache.maxEntries",
            Env:     "TROUBLE_SERVER_PROFILE_CACHE_MAX_ENTRIES",
            Flag:    "server-profile-cache-max-entries",
            Default: "0",
            Kind:    kindInt,
        },
        {
            Path:   "server.profile.token",
            Env:    "TROUBLE_SERVER_PROFILE_TOKEN",
            Flag:   "server-profile-token",
            Kind:   kindString,
            Secret: true,
        },
    }
}

// registry is the single whitelist consulted by the resolver.
// Table prefixes are NOT entries; they are derived from leaf paths (2.2).
var registry = func() []keyMeta {
    ks := []keyMeta{
        // ... all pre-existing keys unchanged ...
    }
    ks = append(ks, serverProfileRegistry()...)
    return ks
}()

The leaves live in the same registry slice as existing keys — no table-level resolver, no parallel path to drift.

2.2 Derive table prefixes from the leaves

// internal/lifecycle/config_registry.go

// tablePrefixes returns every proper dotted prefix of a registered leaf.
// Registering server.profile.name automatically whitelists the [server.profile] header.
func tablePrefixes() map[string]struct{} {
    out := make(map[string]struct{})
    for _, m := range registry {
        parts := strings.Split(m.Path, ".")
        for i := 1; i < len(parts); i++ {
            out[strings.Join(parts[:i], ".")] = struct{}{}
        }
    }
    return out
}

func knownLeaf(path string) bool {
    for _, m := range registry {
        if m.Path == path {
            return true
        }
    }
    return false
}

func knownTable(path string) bool {
    _, ok := tablePrefixes()[path]
    return ok
}

// File walker: acceptable iff it is a leaf or a table container.
func acceptFileKey(path string) error {
    if knownLeaf(path) || knownTable(path) {
        return nil
    }
    return errUnknownFileKey(path) // TROUBLE-LIFECYCLE-001, exit 13
}

// Argv walker: leaves are values; table containers are explicitly refused.
func acceptArgvKey(path string) error {
    if knownLeaf(path) {
        return nil
    }
    if knownTable(path) {
        return errTableNotValue(path) // "refused as tables"
    }
    return errUnknownFlag(path)
}

2.3 Keep required-for-profile keys defaulting to empty

server.profile.name has Default: "". The profile gate:

// internal/lifecycle/profile.go

func (c *Config) validateProfile(profile string) error {
    for _, m := range registry {
        if m.RequiredBy != profile || m.Kind == kindBool || m.Kind == kindInt {
            continue
        }
        if c.stringValue(m.Path) == "" {
            return fmt.Errorf(
                "TROUBLE-LIFECYCLE-001: profile %q requires %s", profile, m.Path)
        }
    }
    return nil
}

A non-empty Default would make c.stringValue(...) == "" unreachable and the requirement dead.

2.4 Project onto a typed config and gate the profile BEFORE the bind preflight

// internal/lifecycle/boot.go

func Boot(ctx context.Context, opts Options) error {
    raw, err := resolveAll(opts) // registry-driven; file key miss -> exit 13
    if err != nil {
        return err
    }

    cfg := Project(raw) // typed config object; one projection, no re-resolution

    // Profile gate first: a refusal must cost zero HTTP responses.
    if err := cfg.validateProfile(opts.Profile); err != nil {
        return err
    }

    // Only now open listeners.
    if err := bindPreflight(ctx, cfg); err != nil {
        return err
    }

    return serve(ctx, cfg)
}

2.5 Extend the argv-surface test's derived inventory

// internal/lifecycle/argv_surface_test.go

func TestArgvSurfaceInventory(t *testing.T) {
    got := probeArgvSurface(t) // returns {accepted []string, tables []string}

    wantLeaves := map[string]struct{}{}
    for _, m := range registry {
        wantLeaves[m.Flag] = struct{}{}
    }
    for _, f := range got.accepted {
        if _, ok := wantLeaves[f]; !ok {
            t.Errorf("flag %q accepted but not a registered leaf", f)
        }
    }

    // Derive, don't hardcode.
    wantTables := tablePrefixes()
    wantTableFlags := make(map[string]struct{}, len(wantTables))
    for p := range wantTables {
        wantTableFlags[flagName(p)] = struct{}{}
    }
    if diff := diffSets(got.tables, wantTableFlags); diff != "" {
        t.Fatalf("refused-as-tables set drifted:\n%s", diff)
    }
}

3. Verification

3.1 RED control — pristine pre-fix tree in a worktree

cd "$REPO"
BASE=$(git rev-parse HEAD~1)          # commit immediately before the fix
git worktree add --detach /tmp/trouble-pristine "$BASE"

cd /tmp/trouble-pristine
CGO_ENABLED=0 go build -o /tmp/trouble-pristine/troubled ./cmd/troubled

cat >/tmp/server-profile.toml <<'TOML'
[server]
addr = "<ip-address>:8080"

[server.profile]
name = "prod"
enabled = true
TOML

set +e
/tmp/trouble-pristine/troubled --config /tmp/server-profile.toml
echo "exit=$?"

Expected (RED control):

TROUBLE-LIFECYCLE-001: unknown file key "server.profile"
exit=13

3.2 GREEN at HEAD — same command, provenance present

cd "$REPO"
CGO_ENABLED=0 go build -o ./troubled ./cmd/troubled

./troubled --config /tmp/server-profile.toml --print-config-keys
#   server.profile.name               prod   source=file
#   server.profile.enabled            true   source=file
#   server.profile.cache.maxEntries   0      source=default
echo "exit=$?"              # 0

Precedence still works:

./troubled --config /tmp/server-profile.toml \
  --server-profile-name=from-flag \
  --print-config-keys | grep -E '^server\.profile\.'
# > server.profile.name   from-flag  source=flag
# > server.profile.enabled true      source=file

3.3 Boot ledger must contain the new keys (proves boot resolution)

./troubled --config /tmp/server-profile.toml \
  --boot-ledger /tmp/boot.jsonl \
  --once

grep '"type":"config"' /tmp/boot.jsonl \
  | grep -E 'server\.profile\.(name|enabled|cache\.maxEntries)'

grep '"type":"config"' /tmp/boot.jsonl | grep 'server.profile.token'
# value must be "[redacted]", never the literal token

3.4 Profile gate fires before any bind

cat >/tmp/server-profile-missing.toml <<'TOML'
[server]
addr = "<ip-address>:8080"

[server.profile]
enabled = true          # name intentionally absent -> refused for --profile prod
TOML

set +e
./troubled --config /tmp/server-profile-missing.toml --profile prod
echo "exit=$?"
# Expected: TROUBLE-LIFECYCLE-001: profile "prod" requires server.profile.name

ss -ltn 2>/dev/null | grep ':8080' && echo "FAIL: bound before gate" || echo "OK: never bound"

3.5 Test suite

cd "$REPO"
CGO_ENABLED=0 go test ./internal/lifecycle/... ./internal/config/...
CGO_ENABLED=0 go test ./...

TestArgvSurfaceInventory must be green without editing a hardcoded table list.

3.6 Clean up

git worktree remove /tmp/trouble-pristine --force

4. Regression checklist


Note: this environment contained no trouble checkout (only /workspace/problem.json), so the document uses the repo's conventional symbol/file names; adjust to the actual identifiers where they differ.

Evidence & signatures

# Evidence
- Problem class: go-config-registry-unknown-file-key
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T02:29:29.861Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Wiring a spec-defined config table into a Go daemon whose resolver keeps a per-key REGISTRY as the only whitelist. Symptom: a config file that the spec documents is refused at boot with 'unknown file key \"server.profile\"' (exit 13) because the registry never learned the new table. Fix pattern that worked: register every LEAF of the table individually (flatten 'server.redis.url' etc. into the same keyMeta list as existing keys) instead of one table key, so precedence (flag > env > file > default), provenance and redaction keep working with no second resolution path; keep required-for-profile keys defaulting to empty string so the profile's refusal stays reachable (a non-empty default makes the requirement dead code); project the resolved struct onto a typed config object and run the profile gate BEFORE the bind preflight so a refusal costs zero HTTP responses; extend the argv-surface test's derived inventory, because a new table changes the 'refused as tables' set and a test that hardcodes that list goes red. Verification that proves it: build the pristine pre-fix tree in a git worktree and capture the exit-13 refusal there as the RED control, then show the same command returning N rows with the new keys source-provenanced at HEAD; also grep the boot ledger's config record for the new keys (that record, not the CLI, is what proves resolution happened at boot).", "environment": "linux/amd64, Go 1.24, CGO_ENABLED=0 single-binary daemon, TOML config with a registry-driven resolver", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-config-registry-unknown-file-key", "provider": "openrouter", "solved_at": "2026-09-19T02:29:29.861Z", "version": ""}

Answer 2

Solution written to ~/SOLUTION.md. Full document below.


Fix: TROUBLE-LIFECYCLE-001: unknown file key "server.profile" (exit 13)

Problem class: go-config-registry-unknown-file-key Component: internal/lifecycle config registry Spec: SPEC-12 §3.1 / SPEC-13 §2.1 Environment: linux/amd64, Go 1.24, CGO_ENABLED=0, single-binary daemon, TOML config, registry-driven resolver


1. Root cause

The resolver keeps one per-key registry (keyMeta) as the only whitelist. Precedence (flag > env > file > default), provenance and redaction are all implemented by walking that single registry; there is no second resolution path.

SPEC-12 §3.1 / SPEC-13 §2.1 added the documented table [server.profile], but the registry was never taught about it. The TOML file walker reaches the table header server.profile, looks it up in the registry, misses, and aborts the boot preflight:

TROUBLE-LIFECYCLE-001: unknown file key "server.profile" (exit 13)

The failure is not a parsing bug and not a missing struct field — it is a missing whitelist entry. The resolver rejects the table prefix server.profile, so the fix must make the resolver aware of the table as a container while keeping the individual leaves as the resolvable units.

Two secondary hazards come with wiring a new table:

  1. Dead profile gate. If a key that a profile requires (e.g. server.profile.name for profile prod) gets a non-empty default, the value == "" check can never fire and the requirement becomes dead code.
  2. Wrong boot ordering. If the profile gate runs after the bind preflight, a refusal still opens the listener and can serve HTTP responses. It must run before any bind.

2. The fix

Five coordinated edits in internal/lifecycle. Substitute exact file/symbol names if they differ locally.

2.1 Register every LEAF of the table, not the table key

// internal/lifecycle/config_registry.go

type kind int

const (
    kindString kind = iota
    kindBool
    kindInt
    kindDuration
)

type keyMeta struct {
    Path       string // canonical dotted key, as written in TOML: "server.profile.name"
    Env        string // environment override:                "TROUBLE_SERVER_PROFILE_NAME"
    Flag       string // CLI flag override (no dashes):       "server-profile-name"
    Default    string
    Kind       kind
    Secret     bool   // redact in provenance / ledger output
    RequiredBy string // profile that must supply a non-empty value; "" = not required
}

// serverProfileRegistry flattens SPEC-13 §2.1 [server.profile] into leaves.
func serverProfileRegistry() []keyMeta {
    return []keyMeta{
        {
            Path:       "server.profile.name",
            Env:        "TROUBLE_SERVER_PROFILE_NAME",
            Flag:       "server-profile-name",
            Default:    "", // MUST stay empty: this is the key the prod profile requires.
            Kind:       kindString,
            RequiredBy: "prod",
        },
        {
            Path:    "server.profile.enabled",
            Env:     "TROUBLE_SERVER_PROFILE_ENABLED",
            Flag:    "server-profile-enabled",
            Default: "false",
            Kind:    kindBool,
        },
        {
            Path:    "server.profile.cache.maxEntries",
            Env:     "TROUBLE_SERVER_PROFILE_CACHE_MAX_ENTRIES",
            Flag:    "server-profile-cache-max-entries",
            Default: "0",
            Kind:    kindInt,
        },
        {
            Path:   "server.profile.token",
            Env:    "TROUBLE_SERVER_PROFILE_TOKEN",
            Flag:   "server-profile-token",
            Kind:   kindString,
            Secret: true,
        },
    }
}

// registry is the single whitelist consulted by the resolver.
// Table prefixes are NOT entries; they are derived from leaf paths (2.2).
var registry = func() []keyMeta {
    ks := []keyMeta{
        // ... all pre-existing keys unchanged ...
    }
    ks = append(ks, serverProfileRegistry()...)
    return ks
}()

The leaves live in the same registry slice as existing keys — no table-level resolver, no parallel path to drift.

2.2 Derive table prefixes from the leaves

// internal/lifecycle/config_registry.go

// tablePrefixes returns every proper dotted prefix of a registered leaf.
// Registering server.profile.name automatically whitelists the [server.profile] header.
func tablePrefixes() map[string]struct{} {
    out := make(map[string]struct{})
    for _, m := range registry {
        parts := strings.Split(m.Path, ".")
        for i := 1; i < len(parts); i++ {
            out[strings.Join(parts[:i], ".")] = struct{}{}
        }
    }
    return out
}

func knownLeaf(path string) bool {
    for _, m := range registry {
        if m.Path == path {
            return true
        }
    }
    return false
}

func knownTable(path string) bool {
    _, ok := tablePrefixes()[path]
    return ok
}

// File walker: acceptable iff it is a leaf or a table container.
func acceptFileKey(path string) error {
    if knownLeaf(path) || knownTable(path) {
        return nil
    }
    return errUnknownFileKey(path) // TROUBLE-LIFECYCLE-001, exit 13
}

// Argv walker: leaves are values; table containers are explicitly refused.
func acceptArgvKey(path string) error {
    if knownLeaf(path) {
        return nil
    }
    if knownTable(path) {
        return errTableNotValue(path) // "refused as tables"
    }
    return errUnknownFlag(path)
}

2.3 Keep required-for-profile keys defaulting to empty

server.profile.name has Default: "". The profile gate:

// internal/lifecycle/profile.go

func (c *Config) validateProfile(profile string) error {
    for _, m := range registry {
        if m.RequiredBy != profile || m.Kind == kindBool || m.Kind == kindInt {
            continue
        }
        if c.stringValue(m.Path) == "" {
            return fmt.Errorf(
                "TROUBLE-LIFECYCLE-001: profile %q requires %s", profile, m.Path)
        }
    }
    return nil
}

A non-empty Default would make c.stringValue(...) == "" unreachable and the requirement dead.

2.4 Project onto a typed config and gate the profile BEFORE the bind preflight

// internal/lifecycle/boot.go

func Boot(ctx context.Context, opts Options) error {
    raw, err := resolveAll(opts) // registry-driven; file key miss -> exit 13
    if err != nil {
        return err
    }

    cfg := Project(raw) // typed config object; one projection, no re-resolution

    // Profile gate first: a refusal must cost zero HTTP responses.
    if err := cfg.validateProfile(opts.Profile); err != nil {
        return err
    }

    // Only now open listeners.
    if err := bindPreflight(ctx, cfg); err != nil {
        return err
    }

    return serve(ctx, cfg)
}

2.5 Extend the argv-surface test's derived inventory

// internal/lifecycle/argv_surface_test.go

func TestArgvSurfaceInventory(t *testing.T) {
    got := probeArgvSurface(t) // returns {accepted []string, tables []string}

    wantLeaves := map[string]struct{}{}
    for _, m := range registry {
        wantLeaves[m.Flag] = struct{}{}
    }
    for _, f := range got.accepted {
        if _, ok := wantLeaves[f]; !ok {
            t.Errorf("flag %q accepted but not a registered leaf", f)
        }
    }

    // Derive, don't hardcode.
    wantTables := tablePrefixes()
    wantTableFlags := make(map[string]struct{}, len(wantTables))
    for p := range wantTables {
        wantTableFlags[flagName(p)] = struct{}{}
    }
    if diff := diffSets(got.tables, wantTableFlags); diff != "" {
        t.Fatalf("refused-as-tables set drifted:\n%s", diff)
    }
}

3. Verification

3.1 RED control — pristine pre-fix tree in a worktree

cd "$REPO"
BASE=$(git rev-parse HEAD~1)          # commit immediately before the fix
git worktree add --detach /tmp/trouble-pristine "$BASE"

cd /tmp/trouble-pristine
CGO_ENABLED=0 go build -o /tmp/trouble-pristine/troubled ./cmd/troubled

cat >/tmp/server-profile.toml <<'TOML'
[server]
addr = "<ip-address>:8080"

[server.profile]
name = "prod"
enabled = true
TOML

set +e
/tmp/trouble-pristine/troubled --config /tmp/server-profile.toml
echo "exit=$?"

Expected (RED control):

TROUBLE-LIFECYCLE-001: unknown file key "server.profile"
exit=13

3.2 GREEN at HEAD — same command, provenance present

cd "$REPO"
CGO_ENABLED=0 go build -o ./troubled ./cmd/troubled

./troubled --config /tmp/server-profile.toml --print-config-keys
#   server.profile.name               prod   source=file
#   server.profile.enabled            true   source=file
#   server.profile.cache.maxEntries   0      source=default
echo "exit=$?"              # 0

Precedence still works:

./troubled --config /tmp/server-profile.toml \
  --server-profile-name=from-flag \
  --print-config-keys | grep -E '^server\.profile\.'
# > server.profile.name   from-flag  source=flag
# > server.profile.enabled true      source=file

3.3 Boot ledger must contain the new keys (proves boot resolution)

./troubled --config /tmp/server-profile.toml \
  --boot-ledger /tmp/boot.jsonl \
  --once

grep '"type":"config"' /tmp/boot.jsonl \
  | grep -E 'server\.profile\.(name|enabled|cache\.maxEntries)'

grep '"type":"config"' /tmp/boot.jsonl | grep 'server.profile.token'
# value must be "[redacted]", never the literal token

3.4 Profile gate fires before any bind

cat >/tmp/server-profile-missing.toml <<'TOML'
[server]
addr = "<ip-address>:8080"

[server.profile]
enabled = true          # name intentionally absent -> refused for --profile prod
TOML

set +e
./troubled --config /tmp/server-profile-missing.toml --profile prod
echo "exit=$?"
# Expected: TROUBLE-LIFECYCLE-001: profile "prod" requires server.profile.name

ss -ltn 2>/dev/null | grep ':8080' && echo "FAIL: bound before gate" || echo "OK: never bound"

3.5 Test suite

cd "$REPO"
CGO_ENABLED=0 go test ./internal/lifecycle/... ./internal/config/...
CGO_ENABLED=0 go test ./...

TestArgvSurfaceInventory must be green without editing a hardcoded table list.

3.6 Clean up

git worktree remove /tmp/trouble-pristine --force

4. Regression checklist


Note: this environment contained no trouble checkout (only /workspace/problem.json), so the document uses the repo's conventional symbol/file names; adjust to the actual identifiers where they differ.

Evidence & signatures

# Evidence
- Problem class: go-config-registry-unknown-file-key
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-19T02:29:29.861Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Wiring a spec-defined config table into a Go daemon whose resolver keeps a per-key REGISTRY as the only whitelist. Symptom: a config file that the spec documents is refused at boot with 'unknown file key \"server.profile\"' (exit 13) because the registry never learned the new table. Fix pattern that worked: register every LEAF of the table individually (flatten 'server.redis.url' etc. into the same keyMeta list as existing keys) instead of one table key, so precedence (flag > env > file > default), provenance and redaction keep working with no second resolution path; keep required-for-profile keys defaulting to empty string so the profile's refusal stays reachable (a non-empty default makes the requirement dead code); project the resolved struct onto a typed config object and run the profile gate BEFORE the bind preflight so a refusal costs zero HTTP responses; extend the argv-surface test's derived inventory, because a new table changes the 'refused as tables' set and a test that hardcodes that list goes red. Verification that proves it: build the pristine pre-fix tree in a git worktree and capture the exit-13 refusal there as the RED control, then show the same command returning N rows with the new keys source-provenanced at HEAD; also grep the boot ledger's config record for the new keys (that record, not the CLI, is what proves resolution happened at boot).", "environment": "linux/amd64, Go 1.24, CGO_ENABLED=0 single-binary daemon, TOML config with a registry-driven resolver", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-config-registry-unknown-file-key", "provider": "openrouter", "solved_at": "2026-09-19T02:29:29.861Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog