Files
6krrt/plans/admin-profile-management.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.8 KiB

Admin portal: profile visibility and selection

Status: done -- profile CRUD in admin.py

Status: FINAL — decision-complete. §C was answered by the user on 2026-09-04: A, B and C are all in scope. Written 2026-09-04 against main at d08aed3.

The principle this serves

Stated by the user: the admin portal does not need 100% coverage of every configurable lever, but the main features should each have basic functionality and configurability there. Named routing profiles shipped in PR #24 with no portal surface at all — they exist only as code defaults and an auto:<name> string a client has to know to send. That fails the bar.

What exists today

  • BUILTIN_PROFILES in src/dispatcher.py:110-116 — five profiles, defined as predicates over catalog columns:

    profile definition
    default RoutingProfile()
    batch latency_tolerance="batch"
    locality provider="ollama-local"
    bigboybritches min_tier=3
    onlycheaps max_cost_per_1m_completion=0.5
  • config.RoutingProfile (src/config.py:197) — fields provider, min_tier, max_tier, latency_tolerance, max_cost_per_1m_completion, allowed_model_ids. All optional.

  • A profiles: config section is supported but no block exists in config/config.yaml, so today every profile is a code default.

  • No routing.default_profile knob. Bare auto resolves to the built-in default and there is no way to change that without editing code.

  • admin.py:_CONFIG_ALLOWLIST — dotted config paths an operator may edit. Writes go through comment-preserving ruamel.yaml round-trip, are validated through RouterConfig before any byte reaches disk, and back up first. routing.default_flex_preference is already on it and is the exact precedent for what Part B needs.

  • Admin pages: index.html, models.html, decisions.html, controls.html. decisions.html already has a Profile column and filter (PR #24).

Part A — Show what profiles are and what they resolve to

Read-only, and the highest value per unit of work.

A profile's definition is not the useful fact; what it currently admits is. Those differ in ways that are invisible today. Measured on the live catalog on 2026-09-04:

  • bigboybritches (min_tier=3) matches 7 models — but only 4 are reachable interactively, because three are -flex rows and the default latency_tolerance=interactive filters them at the hard-filter stage.
  • locality matches 1 model, and only for file_summarization / diff_checking, because per-model eligible_categories ANDs on top.
  • onlycheaps matches 4, and the cheapest is the LOCAL model at $0.229/1M completion — a measured electricity figure sitting in the same column as cloud list prices.

None of that is derivable from reading min_tier=3.

Add a Profiles panel (new profiles.html, or a section on models.html — implementer's choice, state which and why) showing per profile: its name, whether it is built-in or config-defined, its definition rendered as fields, the count and list of models it currently admits, and the count reachable under interactive specifically.

Compute admitted sets by calling routing.select_candidates with the profile's restrict_to, exactly as live routing does — do NOT reimplement the predicate. metrics.context_ceilings is the precedent: it delegates to select_candidates so its numbers cannot drift from routing's.

A profile that currently admits ZERO models must be visibly flagged. That is the shape of the 422 that cost ~19 hours in incident #3, and a profile can reach it silently through admin deprecations — exactly what happened to the vision-capable set on 2026-09-04.

Part B — Let the operator choose the default profile

Add routing.default_profile (string, defaults to "default"), and put it on _CONFIG_ALLOWLIST so it is editable from controls.html alongside routing.default_flex_preference.

Effect: bare auto — which is what opencode.json and every existing client sends — resolves to the operator's chosen profile instead of the hard-coded default. That is what makes profiles usable without touching client config, and it is the smallest change that turns them from a curiosity into an operational lever.

Constraints:

  • Validate against the known profile set at config load. An unknown name must fail loudly at load, consistent with how auto:nonsense returns 422 rather than silently degrading.
  • auto:<name> on a request still wins over the default. The default only fills in for a bare auto.
  • The control must be a select populated from the live profile list, not a free-text field. A typo here silently redirects all default traffic.
  • Warn in the UI if the chosen default currently admits zero models.

Part C — SCOPE DECISION: create and edit custom profiles?

DECIDED 2026-09-04 by the user: IN SCOPE. Ship A, B and C together.

The cost note below still stands and should shape sequencing — do A and B first so the read-only view and the default selector exist before CRUD is layered on. C is most of the work; treat it as its own wave.

The honest cost difference: _CONFIG_ALLOWLIST edits named scalars. A profile is a structured object with six optional fields, one of them a set of model ids. Persisting one means new machinery — a nested-write path through the ruamel round-trip, a create/delete flow, and a UI for six fields rather than a text input. That is most of the work in this plan.

Sequencing, not deferral: build A and B before C. A and B make profiles visible, understandable and selectable — the "basic functionality and configurability" bar — at a fraction of the cost, and they give C something to build on: the read-only panel from A is where a created profile is verified, and B's default_profile is what C's delete-guard has to protect. Landing C first would mean writing CRUD against a surface nobody can see.

C requirements, now that it is in scope

  • Reuse the validated-write path: comment-preserving ruamel.yaml round-trip, RouterConfig validation before any byte reaches disk, backup first. Do not add a second way to write config.yaml.
  • Built-ins are not editable or deletable. Render them read-only. A config-defined profile whose name collides with a built-in must be rejected at config load with a clear message, not silently override or be silently ignored. Ambiguity about which definition is live would be worse than either.
  • allowed_model_ids needs a multi-select populated from the live catalog, not free text. A typo'd model id yields a profile that silently admits fewer models than intended — the failure this plan exists to make visible.
  • Deleting the profile named by routing.default_profile must be refused, with a message saying which setting depends on it. Same for a rename. The alternative is a config that fails to load on next start, which is the refuse-at-load behaviour of Part B turned into an outage.
  • Creating a profile that admits zero models is allowed but must warn on save, consistent with Part A's flag. It may be deliberate (a profile for a model not yet in the catalog); it must not be silent.
  • Deleting or editing a profile takes effect on reload like any other persisted config edit. Say so in the UI rather than implying it is instant.

Non-goals

  • No ranking changes. Profiles filter; they do not reorder. (Settled in plans/named-routing-profiles.md §3: filter-only.)
  • No new profile fields on RoutingProfile.
  • Do not surface profiles as runtime-only toggles that reset on restart. A default profile is a deployment preference and belongs in persisted config.
  • Do not let the portal auto-fix a zero-model profile by relaxing it. Warn; the operator decides.
  • Do not touch auto:batch resolution.

Success criteria

  • The portal lists every profile — built-in and config-defined — with its definition and the models it currently admits, computed via routing.select_candidates, not a reimplementation.
  • A profile admitting zero models is visibly flagged.
  • routing.default_profile is editable from controls.html as a select populated from the live profile list, persists through the validated round-trip write, and takes effect for bare auto after reload.
  • An invalid default_profile fails at config load with a clear message.
  • A test proves auto:<name> still overrides the configured default.
  • Full suite green with local_energy.enabled both true and false.
  • The user's config/config.yaml tariff lines remain uncommitted and verbatim. Note for the implementer: this plan writes to config/config.yaml programmatically. The write path must preserve unrelated lines — the tariff included — and that is worth an explicit test, not an assumption.