Files
6krrt/plans/config-local-overlay.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.

Now `grep -H '^Status:' plans/*.md` is the answer:

    50 done   3 in progress   2 planned   2 reference   1 parked

Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.

The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.

test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.

Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:55:16 -04:00

180 lines
8.4 KiB
Markdown

# 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.