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
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 --rebaserefuses outright while the file is dirty (observed).- The dangerous variant is
enabled: truesurviving with a blanked tariff: config load refuses by design, so the nextsystemctl restartfails 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_kwhmust not blow away the rest oflocal_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.
RouterConfigis 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_LEVELalready overrideslogging.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 writesconfig/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.yamlstops being dirty in normal operation. That removes the condition behind all six clobberings — checkout, switch, stash, andcommit -amhave nothing to catch.- One answer to "what is actually running on this host": the overlay.
- Plan 5's
local_energy.enabledtest-decoupling becomes moot rather than merely unnecessary; the toggle no longer touches a tracked file at all.
What this requires
_CONFIG_ALLOWLISTstill 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.yamlsays, 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.examplewith the two-line shape and a comment explaining what belongs there (deployment-specific and personal values: tariff,local_energy.enabled, any host-specificbase_url).- A line in
.gitignoreforconfig/config.local.yaml— noteconfig.yaml.bak.*is already ignored, so the section exists. - A short
docs/note and aCLAUDE.mdpointer saying that deployment values belong in the overlay and that a dirtyconfig/config.yamlis 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.
.envalready holdsNEURALWATT_API_KEYand 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_ALLOWLISTpermits. Only the write DESTINATION changes, fromconfig/config.yamltoconfig/config.local.yaml. - Do not mirror admin writes into both files. One value, one home.
Success criteria
- With no overlay present,
load_configreturns exactly what it does today — pinned by a test comparing against the base-only result. - An overlay setting
local_energy.tariff_usd_per_kwhalone leaves every otherlocal_energykey 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 switchandgit stashleave 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 toconfig/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.yamlis gitignored; the.exampleis tracked.- Full suite green with
local_energy.enabledboth 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.yamlvalues are untouched by this plan.