Files
6krrt/plans/admin-profile-writes-to-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

195 lines
8.6 KiB
Markdown

# Admin profile CRUD should write to the overlay
Status: done -- profile CRUD writes config.local.yaml
**Status: FINAL — decision-complete.** Written 2026-09-04 against
`feat/config-local-overlay` at `0570123` (PR #26). Depends on that PR landing.
## Why this exists
`plans/config-local-overlay.md` states the rule without qualification:
> **The admin portal writes to `config/config.local.yaml`. It never writes
> `config/config.yaml`.**
The implementation splits it. Allowlisted scalars go to the overlay; **profile
CRUD writes the base file directly**, documented at `src/admin.py:670`.
This is not a considered exception. It is chronological accident: profile CRUD
shipped in PR #25 (`340453e`), and the overlay decision was made afterwards
(`e5ee92b`). Plan 8 then implemented the new rule for the scalar path it
touched and left the profile path where it was.
## The reasoning is the same one the user already endorsed
Plan 8's justification for the scalar path:
> 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.
Creating `onlycheaps` in the portal is that same act. Nothing about the
argument depends on the value being a scalar rather than a nested object — the
only reason the code diverges is the ordering above.
## What it costs to leave it
The tariff no longer lives in `config/config.yaml`, so the blast radius is far
smaller than the six clobberings Plan 8 was written for. The residual harm is
real but narrower:
- Using the profiles UI **re-dirties a tracked file**. `git pull --rebase`
refuses on a dirty tree — observed, and listed in Plan 8's problem statement.
- A `git switch` or `git checkout` silently discards a profile the operator
just created, with nothing indicating why.
- `commit -am` sweeps profiles into unrelated commits.
The sharpest version: **a partial guarantee is worse than none.** An operator
who has internalized "the portal writes to the overlay, `config.yaml` stays
clean" stops checking. The failure then arrives against an expectation this
project's own fix created.
## Two defects that exist TODAY, independent of the rule
Both were found by reading the code on 2026-09-04, and the first was confirmed
live. They are the reason this is a bug fix and not only a consistency tidy.
### 1. An overlay-defined profile is live in routing and invisible to the portal
`_persisted_profiles()` (`src/admin.py:~789`) reads **base only**:
```python
store = load_config_store_safe(config_path) or {}
return store.get("profiles") or {}
```
But `load_config` deep-merges the overlay, so a `profiles:` block in
`config/config.local.yaml` **is** live in `cfg.profiles`. Confirmed by writing
a temporary `ghosttest` profile into the overlay:
| reader | sees |
|---|---|
| `load_config` (what routing uses) | `['ghosttest']` |
| `_persisted_profiles()` (what the portal lists) | `[]` |
So the portal's profile list can already disagree with what the router will
actually accept as `auto:<name>`. That is the same class of failure as the
vision-ceiling incident — a real state, computed correctly somewhere, never
surfaced.
Note this is reachable **today** by hand-editing the overlay, before any of
this plan lands. It is not created by the redirect; the redirect is what fixes
it, because the portal will finally be reading the file it writes.
### 2. Delete reads merged config but writes the base file
`admin_profile_delete` deliberately reads the merged store so the
`default_profile` guard is correct regardless of which file set it:
```python
merged = _load_merged_config_store(config_path, config_local_path)
persisted_profiles = merged.get("profiles") or {}
```
then deletes from base:
```python
_persist_config_block(config_path, ("profiles", name), None, delete=True)
```
For an overlay-defined profile that is found-then-not-deletable: the existence
check passes on merged, the write raises `KeyError`, and the operator gets a
**404 for a profile the portal just confirmed exists**. The endpoint is already
half-overlay-aware, which is a good sign the split was never intentional.
## The rule
| operation | destination |
|---|---|
| create | `config/config.local.yaml` |
| update | `config/config.local.yaml` |
| delete | overlay-defined profiles only |
| base-defined profile | **read-only**, alongside built-ins |
## The deletion wrinkle, and why it resolves rather than blocks
Deep merge can add and override. It cannot express *remove*. A profile defined
in base `config/config.yaml` cannot be deleted from the overlay without a
tombstone, and tombstones are exactly the config-framework machinery Plan 8's
non-goals rule out ("resist the config-framework instinct").
**Resolve it by classification, not mechanism.** A profile someone committed to
`config/config.yaml` *is* a project artifact — the same category as a built-in.
Render it read-only, refuse edit and delete, and say why in a message that
points at the repo.
This is cheap because the refusal path already exists at three sites, each
guarding on `name in BUILTIN_PROFILES`:
- `src/admin.py:902` — create
- `src/admin.py:936` — update
- `src/admin.py:960` — delete
The change is a widened predicate and a distinct message, not new machinery.
Keep the two refusals **distinguishable**: a built-in cannot be changed at all,
while a base-defined profile can be changed by editing the repo. Collapsing
both into "read-only" would tell the operator less than the code knows.
It also gives the provenance display Plan 8 already built a real job: base vs
overlay is precisely what explains why a given profile is not editable.
## Interactions to get right
- **`routing.default_profile` may name a profile from either file.** The
existing delete guard already reads merged config for this; keep it. Deleting
the current default stays refused (422), and that check must run against the
merged view, not the overlay alone.
- **A name collision between base and overlay** must resolve to the overlay
(standard merge precedence) and be **visible as such** in the listing, not
silently deduplicated. Two definitions for one name is the ambiguity Plan 7
refused to accept for built-ins; the same reasoning applies here.
- **Built-in name collisions stay rejected at config load**, unchanged.
- **`_profile_probe` / `_probe_candidate_zero_admit` do not change.** Admission
is still computed via `routing.select_candidates`; this plan moves a write
destination and a visibility source, not any routing logic.
- **The zero-admit warning still fires** on save, unchanged.
## Non-goals
- No tombstone or delete-marker syntax in the overlay. If a base-defined
profile must go, it goes through git.
- Do not mirror profile writes into both files. Plan 8 settled that: one value,
one home.
- Do not migrate existing base-defined profiles into the overlay automatically.
Same reasoning as Plan 8's Migration section — this plan does not decide
where someone else's committed config lives.
- No changes to `RoutingProfile`'s fields, to built-in definitions, or to how
`auto:<name>` resolves.
- Do not widen `_CONFIG_ALLOWLIST`.
## Success criteria
- Creating a profile through the portal writes `config/config.local.yaml` and
leaves `config/config.yaml` **byte-identical** — asserted directly, since the
existing `test_profile_create_preserves_comments_and_does_not_create_overlay`
asserts the opposite and must be inverted rather than deleted.
- Updating an overlay-defined profile writes the overlay only.
- Deleting an overlay-defined profile removes it from the overlay only.
- A base-defined profile returns 403 on update and delete, with a message
distinct from the built-in message and naming `config/config.yaml`.
- **The portal lists overlay-defined profiles.** A test writes a profile into
the overlay and asserts `GET /admin/api/profiles/` returns it — this is the
regression test for defect #1 and must fail on current `main`.
- A base/overlay name collision resolves to the overlay and is labelled with
its source in the listing.
- Deleting the profile named by `routing.default_profile` is still refused when
that setting comes from **either** file.
- The overlay is created on first profile write when absent, with the same
header comment the scalar path writes.
- Backup-before-write and validation-of-merged-config still hold on the profile
path.
- Full suite green with `local_energy.enabled` both true and false.
- The user's `config/config.local.yaml` values are untouched: tariff `0.159`
and `enabled: true` verbatim, verified after the test run, not before.