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

8.4 KiB

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:

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.