# A gitignored local config overlay Status: done -- config/config.local.yaml **Status: FINAL — decision-complete.** Written 2026-09-04 against `main` at `340453e`. ## The problem, with a count Deployment-specific values live as **uncommitted modifications to a tracked file**. On this machine that is `local_energy.tariff_usd_per_kwh: 0.159` and `local_energy.enabled: true` in `config/config.yaml`. That arrangement has failed **six times in one session**: | # | cause | |---|---| | 1-3 | an agent ran `git checkout` / `git stash` / `git restore` to clean the tree | | 4-5 | `git commit -am` swept the file into a commit — **twice**, each needing a history rewrite | | 6 | an ordinary `git switch` between branches | Note the last one: once the value is correctly *absent* from git, **every** checkout, branch switch, pull or rebase wipes it. That is not a mistake anyone made — it is the intended fix behaving as designed. The arrangement is the bug. It also has teeth beyond annoyance: - Metering silently stops, so measured GPU cost stops accruing. - `git pull --rebase` refuses outright while the file is dirty (observed). - The dangerous variant is `enabled: true` surviving with a blanked tariff: config load **refuses** by design, so the next `systemctl restart` fails and the router does not come back. - Plan 5 spent two todos decoupling tests from deployment config — work that exists only because the two are entangled in one file. ## The fix An optional `config/config.local.yaml`, gitignored, deep-merged over `config/config.yaml` before validation. `load_config` (`src/config.py:1000`) is currently: ```python raw = yaml.safe_load(path.read_text()) return RouterConfig(**raw) ``` The overlay slots in between those two lines. That single change covers every consumer, because `poller.py`, `feedback.py`, `eval_proficiency.py`, `seed_local_dispatch_energy.py`, `admin.py` and the dispatcher all go through `load_config`. ### Merge rules — state these in the code comment, not just here - **Absent overlay ⇒ behaviour identical to today.** No file, no change. - **Deep merge for mappings**, key by key. An overlay naming only `local_energy.tariff_usd_per_kwh` must not blow away the rest of `local_energy`. - **Lists REPLACE wholesale**, never append. Appending is ambiguous and surprising; replacing is predictable. Say so explicitly. - **Validate the MERGED result once.** Do not validate the two files separately. `RouterConfig` is strict (`extra="forbid"`), so a typo'd key in the overlay must fail loudly at load — the overlay is not an escape hatch from strictness, and this project already learned that a setting which "loads cleanly, does nothing, and still looks configured" is worse than an error. - **Precedence: env > overlay > base.** `LLM_ROUTER_LOG_LEVEL` already overrides `logging.level`; that stays the outermost layer. ## Admin writes go to the OVERLAY, not the base file **Decided by the user, 2026-09-04, and it is the right call.** An earlier draft of this plan had admin edits refuse when a key was overlay-shadowed. That was worse: it papered over the shadowing rather than removing it, and it left `config/config.yaml` dirty, which is the actual root cause of every clobbering listed above. The rule instead: > **The admin portal writes to `config/config.local.yaml`. It never writes > `config/config.yaml`.** The reasoning is that an operator changing a knob in a loopback-only admin portal is making a **local operational decision**, not a project decision. Someone changing a project default edits `config/config.yaml` in the repo and commits it, deliberately, through git. The two paths should not share a destination. Consequences, all of them good: - **No shadowing trap.** Writes land where the overrides live, so an edit always takes effect. The "succeeds, validates, backs up, changes nothing" failure cannot occur. - **`config/config.yaml` stops being dirty in normal operation.** That removes the condition behind all six clobberings — checkout, switch, stash, and `commit -am` have nothing to catch. - **One answer to "what is actually running on this host"**: the overlay. - Plan 5's `local_energy.enabled` test-decoupling becomes moot rather than merely unnecessary; the toggle no longer touches a tracked file at all. ### What this requires - **`_CONFIG_ALLOWLIST` still governs WHICH keys may be edited.** Only the destination changes. Do not widen it. - **Create the overlay on first write** if absent, with a header comment saying it is machine-local, gitignored, and written by the admin portal. - **The same safety guarantees apply to the overlay**: comment-preserving ruamel round-trip, backup first (extend the existing `config.yaml.bak.*` convention to the overlay and gitignore it too), and validation of the MERGED config — base + overlay — before any byte reaches disk. Validating the overlay alone would accept a fragment that is invalid in context. - **The config view shows provenance**: effective value plus whether it comes from base or overlay. Still needed — now to explain why a value differs from what `config/config.yaml` says, rather than to explain a refusal. - **A write that would make the merged config invalid is refused**, unchanged from today's behaviour. ### The one real cost, stated plainly `config/config.yaml` ceases to describe the running configuration on a host that has used the portal. A reader of the repo sees defaults, not live state. That is inherent to any override mechanism and is the price of the arrangement; provenance display and the docs note are the mitigation. Do not try to solve it by mirroring writes into both files — two sources of truth for one value is worse than one indirection. ## Migration Do **not** move the current values automatically. The tariff is user data and this plan must not decide where it lives on its own. Ship instead: - `config/config.local.yaml.example` with the two-line shape and a comment explaining what belongs there (deployment-specific and personal values: tariff, `local_energy.enabled`, any host-specific `base_url`). - A line in `.gitignore` for `config/config.local.yaml` — note `config.yaml.bak.*` is already ignored, so the section exists. - A short `docs/` note and a `CLAUDE.md` pointer saying that deployment values belong in the overlay and that a dirty `config/config.yaml` is now a smell rather than the norm. - A one-line manual step in the README setup for operators who want it. ## Non-goals - Do not make the overlay required. A fresh clone with no overlay must work. - Do not support more than one overlay file, or a directory of fragments. One optional file; resist the config-framework instinct. - Do not use the overlay to hold secrets. `.env` already holds `NEURALWATT_API_KEY` and that stays. - Do not relax `extra="forbid"` for overlay keys. - Do not auto-migrate the user's existing values (see Migration). - Do not change what `_CONFIG_ALLOWLIST` permits. Only the write DESTINATION changes, from `config/config.yaml` to `config/config.local.yaml`. - Do not mirror admin writes into both files. One value, one home. ## Success criteria - With no overlay present, `load_config` returns exactly what it does today — pinned by a test comparing against the base-only result. - An overlay setting `local_energy.tariff_usd_per_kwh` alone leaves every other `local_energy` key intact (deep merge, not replacement). - An overlay list replaces rather than appends, with a test. - An unknown key in the overlay raises at load, same as in the base file. - `git checkout`, `git switch` and `git stash` leave the overlay untouched — the whole point. Prove it with a test that writes an overlay, runs a checkout of a tracked file, and asserts the overlay survives. - An admin edit writes to `config/config.local.yaml`, never to `config/config.yaml`; a test asserts the base file is byte-identical after a portal edit. - The overlay is created on first write when absent, with its header comment. - A portal edit that would make the MERGED config invalid is refused, and neither file is modified. - The config view shows per-key provenance (base vs overlay). - `config/config.local.yaml` is gitignored; the `.example` is tracked. - Full suite green with `local_energy.enabled` both true and false — and note that once the overlay exists, that toggle no longer needs to touch a tracked file at all, which is the cleaner way to satisfy Plan 5's invariant. - The user's current `config/config.yaml` values are untouched by this plan.