Files
6krrt/plans/admin-portal-functional-sweep.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

378 lines
17 KiB
Markdown

# Admin portal functional sweep -- 2026-09-08
Status: in progress -- 12 findings; #1-#8, #10-#12 fixed, #9 was not a defect
Every page, control, and API endpoint under `/admin` exercised end to end
against real data, on a throwaway instance bound to 8081.
## How it was tested
```
sqlite3 /home/alee/Sources/6krrt/router.db \
"VACUUM INTO '<worktree>/router.db'" # real catalog + 23,844 decisions
PYTHONPATH=<worktree>/src HF_HOME=<repo>/.hf-cache \
.venv/bin/uvicorn dispatcher:app --host 127.0.0.1 --port 8081
```
cwd = the worktree, so `config/config.yaml`, `router.db` and the admin
overlay all resolve inside it. Production 8080 and
`/home/alee/Sources/6krrt/config/config.local.yaml` were untouched --
verified by md5 before and after (`1ec1734...`, unchanged) and by 8080
answering 200 throughout.
**Not run, deliberately.** `Seed Energy` spends real NeuralWatt credits
(`?samples=5`, 65+ billed completions) and `Restart Service` shells
`systemctl --user restart llm-router.service`, which is the *production*
unit regardless of which instance served the click. Both were read in
source instead.
## Findings
Ordered by consequence. Every item was reproduced, not inferred.
### 1. Profile edit silently drops an `allowed_model_ids` entry that is not in the current catalog
`admin/frontend/profiles.html`
The editor's `allowed_model_ids` control is a `<select multiple>` populated
from the 446-row catalog. An id stored in the profile that has no matching
`<option>` is simply not selected -- and Save posts the selection, so the
stored value is gone.
Reproduced: a profile holding `allowed_model_ids: ["totally-not-a-model"]`
was opened with Edit and saved **with no changes**. It went from
`admits 0 models` (warning triangle) to `no filters / admits 40 models`.
This fails **open**. The realistic trigger is a model that left the catalog
-- deprecated by the poller, renamed upstream, or pruned by the OpenRouter
allowlist -- at which point an unrelated edit to `min_tier` quietly converts
a locked-down profile into one that admits everything. Nothing warns.
Ids that *are* in the catalog round-trip correctly (verified: `gemma-4-31b`,
`kimi-k3` came back preselected), so this only bites on drift.
### 2. Admin request bodies are plain `BaseModel`, so unknown keys are ignored
`src/admin.py` -- `_CloudFallbackBody`, `_ProfileCreateBody`,
`_ProfileUpdateBody`, `_ProviderUpdateBody`, `_AvailabilityBody`,
`_AllowlistAddBody`, `_ValueBody`
CLAUDE.md already documents this exact hazard and closed it for config
models via `StrictModel(extra="forbid")`. The admin API surface never got
the same treatment.
Worst instance is destructive:
```
POST /admin/api/cloud-fallback-config
{"base_url":"https://api.neuralwatt.com/v1","model":"deepseek-v4-flash", ...}
-> 200 {"message":"A restart is required for this change to take effect"}
```
The body is nested (`{"cloud_fallback": {...}}`), so every flat key is
discarded, `cloud_fallback` defaults to `None`, and `None` is the documented
*remove the block* signal. A configured cloud fallback is **deleted** and the
caller is told the save succeeded. Confirmed: overlay went from a populated
`classifier.cloud_fallback` to no such key, with a 200 both times.
Note the asymmetry -- the inner dict *is* validated strictly
(`{"timeout_secondz":20}` -> 422 "Extra inputs are not permitted"); only the
outer wrapper is loose.
Same class, non-destructive: `POST /admin/api/profiles/`
`{"name":"typoprof","min_teir":3}` returns 200 and creates a profile with
every filter null.
### 3. The `stale` availability override does nothing
`admin/frontend/models.html` offers active / deprecated / stale. Only
`deprecated` is ever read -- `metrics._admin_deprecated_models` and
`dispatcher._admin_deprecated_models` both query
`WHERE availability = 'deprecated'`.
Measured on `/route` (`coding_refactor`, tier 1, 5k ctx):
| state | `candidates_considered` |
|---|---|
| `glm-5.2-fast` overridden to **stale** | 27 |
| stale override cleared | 27 |
| `gemma-4-31b` overridden to **deprecated** | 27 |
| deprecated override cleared | **28** |
Deprecated works. Stale is a control that persists a row and changes
nothing.
### 4. `/v1/models` advertises admin-deprecated models, and pins against them are not refused
`src/dispatcher.py:3645` reads `models.availability = 'active'` straight from
the table and never consults `admin_model_overrides`.
`_resolve_pinned_provider` has no override check either.
With `gemma-4-31b` admin-deprecated, it stayed in `/v1/models` (51 entries)
while `/route` correctly excluded it. So a client enumerating the endpoint
sees a model the operator has switched off, pins it, and gets it dispatched.
The same function already hides `ollama-local` rows under gaming mode, with
a comment giving the reason: listing one would "advertise a model every pin
against it is about to be refused for". The override case has neither the
hiding nor the refusal.
### 5. Decisions search is silently scoped to the newest 1000 rows
`admin/frontend/decisions.html`. Subtitle: "Full routing decision history --
filter, search, and inspect".
The page loads a fixed 1000-row window; every filter and the search box
operate on that client-side slice. The counter reads "N of 1000", which
looks like a total.
- `route_decisions` holds **23,844** rows
- **10,394** of them name a kimi model
- searching `kimi` on the page reports **"0 of 1000"**
A confident zero on a page that claims to hold the full history. The API
itself is fine (`/admin/api/decisions?limit=5000` returned 2000 rows), so
this is a frontend windowing choice, not a backend limit.
Also on this page: rows are not clickable and there is no detail affordance,
though the subtitle promises "inspect"; the FLAGS column has no legend.
### 6. Two reciprocal navigation gaps
- `admin/frontend/providers.html` has no `href="/admin/proficiency"`
- `admin/frontend/proficiency.html` has no `href="/admin/providers"`
Every other page carries both. Net effect: you cannot get from Providers to
Proficiency or back without editing the URL.
### 7. `POST /admin/api/providers/{name}` has no read-only guard, and the result can be unremovable
`src/admin.py:1378`. `admin_provider_update` validates the URL and the model,
then persists -- with no provenance check at all. Compare
`admin_provider_delete` (403 for a base-config provider) and
`admin_profile_update` (403, "add an overlay profile with the same name to
override it").
Editing `neuralwatt` -- shown in the UI as `base config` / `read-only` --
returned 200 and wrote a full `dispatch_providers.neuralwatt` block into
`config.local.yaml`.
The trap is what happens next: `DELETE /admin/api/providers/neuralwatt`
refuses with 422 because it is `dispatch_settings.default_provider`. The
overlay entry is now **unremovable through the portal**; the only way out is
hand-editing `config.local.yaml`, the one file in this deployment that is
not in git.
The UI hides the edit control for base-config providers, so this is
API-reachable only -- but the portal is the only thing that writes that file.
### 8. `cloud_fallback.base_url` is not URL-validated
```
POST /admin/api/cloud-fallback-config
{"cloud_fallback":{"base_url":"notaurl","model":"x", ...}} -> 200
```
and `base_url: notaurl` lands in the overlay. `POST /api/providers/{name}`
rejects `ftp://nope` with 422 via `_validate_provider_url`; the same check is
absent here, and this block is what the router falls back to when the local
classifier dies.
### 9. Allowlist delete always reports success
`DELETE /admin/api/providers/openrouter/allowlist/nope/nope` -> 200
`{"deleted":"nope/nope"}`. Bare `DELETE`, no rowcount check. Profile delete
and model-override delete both 404 correctly on a missing target.
### 10. Allowlist add cannot update a note
`INSERT OR IGNORE`. Re-adding `sandbox/test-model` with `note:"dup"`
returned 200 carrying the **original** note. There is no way to correct a
note through the API or the UI.
## Smaller things, all reproduced
- **"Apply Feedback" only previews.** `controls.html:541` sends
`?dry_run=true`. The button says Apply; there is no way to actually apply
from the portal.
- **"Seed Energy" spends money with no confirmation** and is styled
identically (`btn-success`) to the free Refresh Catalog next to it.
`?samples=5` across the catalog is 65+ billed completions.
- **"Restart Service" has no confirmation.** It is `btn-danger`, which
helps, but it drops in-flight requests on one click -- while deleting a
profile requires a two-step confirm.
- **`Manage` on a provider that has no allowlist is a dead end.**
neuralwatt's panel opens, says "No models on the allowlist" (the truth is
"this provider has no allowlist"), and Browse catalog preview fails with a
generic "Could not load catalog preview." The real reason -- 409
"allowlist is not required for this provider" -- never reaches the user.
The summary line "0 allowed" reads as *zero permitted* when it means
*unrestricted*.
- **The profile editor's model picker offers all 446 rows**, including
allowlist-pruned and deprecated OpenRouter models, with no filter. The
Models page has a "Show deprecated / stale" toggle defaulting to off;
this picker has none, so picking one silently yields a zero-admit profile.
- **Allowlist catalog search resets after every Add**, so adding several
models from one search means retyping it each time.
- **Allowlist Remove has no confirm step**, unlike profile Delete.
- **Gaming-mode 409 after saving cloud fallback in the portal** tells you to
"uncomment the classifier.cloud_fallback example in config/config.yaml"
rather than "restart to pick up the block you just saved". `cfg` binds at
import, so the refusal itself is correct; the message points at the wrong
remedy.
- **Switching classifier mode away from `local_encoder` leaves the
`encoder:` block in the overlay.** Only `cloud_primary` is cleaned
(`deletions`). Harmless -- validators accept it -- but it is stale state.
- Overlay YAML writes nulls as bare `provider:` keys (ruamel). Parses fine,
reads oddly.
## What works
Stated because it is most of the surface, and because the validation in
particular is unusually good.
**Pages.** All seven render and populate from real data. Dashboard
Calls/Cost/Energy toggle, all five history ranges, the warnings popover, the
quota strip, verdict mix, category breakdown. Models list + "Show deprecated
/ stale" (45 -> 446). Decisions filters (kind, category, tier, search),
pagination, page-size. Proficiency matrix, category column-narrowing,
model search, only-measured, sort, and the source control's cell dimming
(413/495 cells at `outcome_blended`) with a legend that distinguishes
measured from inherited.
**Profiles.** Create, duplicate-with-prefill (`onlycheaps` ->
`onlycheaps-copy`), inline edit, two-step delete, live admission counts that
match `select_candidates`, the zero-admit warning chip, and the
restart-required banner. Built-ins correctly read-only.
**Providers.** Create, delete, allowlist add/remove, live catalog preview
(422 models), search filter, "Added" state on entries already allowlisted.
**Model overrides.** Set and clear, including slash-bearing ids
(`deepseek/deepseek-v4-flash`), with `effective_availability` /
`is_overridden` reported correctly and deprecation genuinely removing the
model from routing.
**Controls.** All nine runtime knobs flip and read back. All twelve
persisted config keys write to the overlay with correct `base` / `overlay`
provenance.
**Error paths** -- every one of these returned the right status with a
message that explains itself:
| probe | result |
|---|---|
| `min_tier: 3, max_tier: 1` | 422, names both values |
| `latency_tolerance: "turbo"` | 422 |
| `logging.level: "screaming"` | 422, lists the four valid values |
| `quality_tolerance: -5` | 422, "must be in [0, 1)" |
| `objective.credit_attenuation.enabled` | 403, not editable (matches CLAUDE.md) |
| `default_profile: "ghostprofile"` | 422, lists the five real profiles |
| `confidence_threshold: 80` | 422, "returns a 0-1 probability, not a percent" |
| `encoder.device: "tpu"` | 422 |
| `cloud_llm` with neither primary nor auto | 422 |
| gaming mode without `cloud_fallback` | 409 (runtime) **and** 422 (persisted) |
| duplicate profile / builtin profile / missing profile | 409 / 403 / 404 |
| delete the current `default_profile` | 422, says to repoint it first |
| `history?range=bogus` | 400 |
| allowlist ops on a non-allowlist provider | 409 |
| unknown provider | 404 |
**Triggers.** `Refresh Catalog` ran clean (19 NeuralWatt + 29 OpenRouter
upserted, 393 pruned by the allowlist) and surfaced the catalog sanity-floor
warning CLAUDE.md asks for: "[openrouter] fetched only 29 models (current DB
has 425); proceeding but catalog may be truncated". `Apply Feedback`
(dry-run) produced a correct per-model failure/success tally.
## Two more, found while fixing #1
### 11. The model picker offered every deprecated row
The `allowed_model_ids` control was populated from an unfiltered
`/admin/api/models`: 446 options, of which 393 were OpenRouter rows the
provider allowlist had already pruned and the poller had marked
`deprecated`. Picking one produced a profile that admits nothing, with
nothing on screen saying why. models.html filtered the same payload behind a
"Show deprecated / stale" toggle defaulting off; the picker did not.
### 12. `config/config.local.yaml` breaks 9 tests
Not a portal bug, but it bites anyone running the suite on a configured
machine. Reproduced both directions on the same tree:
| overlay present | result |
|---|---|
| no | 1523 passed |
| yes (`classifier.mode: local_encoder`) | 9 failed, 1514 passed |
The nine are in `test_classifier_backoff.py` (5), `test_gaming_mode.py` (3)
and `test_classifier_modes_dispatch.py` (1) -- e.g.
`assert 'local_encoder' == 'local_llm'`. They load the repo's real
`config/config.yaml` and pick up the machine-local overlay beside it, so a
deployment that has configured `classifier.mode` cannot run its own tests
clean. This deployment has exactly that overlay, so `pytest` fails 9 today
regardless of any change.
## Fixed on `fix/admin-profile-model-visibility`
Findings **#1**, **#11**, and the picker half of the smaller items:
- `GET /admin/api/models` now defaults to routable rows only
(50 here, of which the 30 OpenRouter ones are exactly the 30 allowlist
entries). `?include_unroutable=true` returns all 446, and models.html --
the availability table, the one page that legitimately wants them -- is
the only caller that passes it. Every present and future picker gets the
filtered default. Allowlist a model, poll, and it appears.
- The profile detail pane renders `admitted_models` as chips instead of
hiding it in a `title=` tooltip truncated to 8 of 40.
- The `allowed_model_ids` control is a two-pane Available / Selected picker.
What is chosen is always on screen, filtering narrows Available only,
"Add all" takes the whole filtered set, and a stored id that is no longer
routable stays in Selected flagged `not routable` -- which is what closes
the silent-drop in #1: opening Edit and saving untouched now preserves it.
- The list summary says `2 models` rather than `1 filter`.
### The rest, in the order they were done
- **#2** `6b9826c` -- every admin body inherits `_AdminBody`
(`extra="forbid"`). Immediately found a real client/server mismatch: the
profiles UI sent `name` on the update path, which the body model has no
field for. It had been silently dropped.
- **#7** `6b9826c` -- `POST /api/providers/{name}` refuses a base-only
definition, as delete already did. Also corrected both profile 403s,
which advised "add an overlay profile with the same name to override it"
-- something `admin_profile_create` rejects with a 409.
- **#3 / #4** `43645d4` -- both off-states exclude, and the override binds
`/v1/models` and pinned requests as well as `/route`.
`_admin_deprecated_models` renamed `_admin_excluded_models`, since
"deprecated" no longer describes what it returns.
- **#8 / #10** `281f4fb` -- `_validate_provider_url` applied to
`cloud_fallback` and `cloud_primary`; allowlist add upserts so a note can
be corrected.
- **#5 / #6** `48df93b` -- `X-Total-Count` on the decisions endpoint, and
the page names the window and the history separately. Both nav gaps
closed, with a test that derives the page list so a new admin page fails
until it is linked.
- **#12** `80d0e6a` -- `ROUTER_IGNORE_LOCAL_CONFIG`, set in
`tests/conftest.py`. 1613 tests now pass identically with and without the
machine's overlay in place.
### #9 was not a defect
The sweep flagged the allowlist DELETE returning 200 for an absent entry as
a false confirmation, and the fix was written -- then reverted, because it
broke `test_allowlist_delete_idempotent`, a test that pins that exact
behaviour with a docstring saying so. That is a decision, not an oversight.
The inconsistency it noticed is still real and is the one open question
left here: profile delete and model-override delete both 404 on a missing
target while this one does not. Which way that resolves is a call to make,
not a bug to fix.
### Smaller items still open
The nine UX items listed above are unaddressed except the picker ones: the
"Apply Feedback" button still only previews, "Seed Energy" still spends
money with no confirmation, `Manage` on a non-allowlist provider still
opens a dead-end panel, and the decisions FLAGS column still has no legend.