Skip to content

feat(settings): the settings foundation — one declaration, manifest v2, and docs that can't drift - #66

Merged
QR-Madness merged 5 commits into
masterfrom
feat/genome-foundation-settings
Aug 6, 2026
Merged

feat(settings): the settings foundation — one declaration, manifest v2, and docs that can't drift#66
QR-Madness merged 5 commits into
masterfrom
feat/genome-foundation-settings

Conversation

@QR-Madness

Copy link
Copy Markdown
Owner

Wave 1 of the settings campaign. Settings quality had crept: 21 sections, ~200 knobs, every section individually well-written but the aggregate hard to enter — "even for LLM experts, you look at it and wonder where to start."

This wave builds the foundation the rest of the campaign hangs off, and pays down the defects that foundation exposes.

The problem it fixes

The config write surface lived in four hand-synced places: config_update's key tuples, the manifest's _CONFIG_WRITE_ROUTES mirror (carrying a literal "keep in lockstep" comment), and prose in OpenApi.yaml and endpoints.md. They had drifted, invisibly and in both directions — and the class of bug it invites had already shipped once (the Images section silently dropped every save while toasting success).

Found live during this work, now fixed by construction:

  • research / web_research were writable but reported read-only — 9 keys under-reported.
  • ambassador's speech_model / voice / transcription_model were advertised as writable while the handler dropped every save.
  • search.source_policy advertised per-leaf writes; patching one leaf wiped its siblings.
  • models.roles could 400 mid-walk, leaving the process-global config half-written until the next reload.
  • /api/memory/settings was an unvalidated write path into config.json via the legacy trajectory_compression_* bridge, against a second copy of the defaults.

What landed

settings_registry.py — one declaration. Records deltas only; type and default still come from DEFAULT_CONFIG and the memory kit's pydantic Settings. Undeclared ⇒ read-only, never silently writable. Bespoke sections (providers' per-request accept-set, context_limits' wildcard subtree) register planners, so even the irregular cases live in one file. Writes are two-phase — validate the whole payload, then apply.

Manifest v2 adds constraints, tier, ui_section, nullable, empty_means, write_mode, and authored help, each emitted only where declared. Writability now derives from the registry rather than mirroring it.

The client reads it. Field kit v2 shows each control's shipped default, marks what you've changed, and offers to put it back; help is a first-class popover, not a title= attribute. Fixed the a11y defects while the fields were open — five primitives had unassociated labels, and NumberField's accessible name resolved to its hint sentence. Sections now render inside error boundaries, so a section that throws no longer takes the app down.

Settings open on Overview — what you've changed from the defaults, grouped by section — instead of an API-key admin page.

Golden Recall refit. Each technique owns its knobs (HyDE and Self-Query were rendering in two places), tiers replace the three ad-hoc "Advanced"/"Experimental" dialects, and all 24 keys carry the full write-up: what it is, how it works, when to change it, what to watch. This is the register the other sections refit toward on a cadence.

The docs generate themselves. scripts/gen_settings_reference.py renders the whole catalogue from the same declarations, gated by task docs:check. Settings and documentation can no longer drift — the guarantee is structural, not a discipline.

Behaviour changes worth knowing

  • trajectory_compression_* on /api/memory/settings now 400s, naming /api/config/update. The client stopped writing these releases ago; the route was an unvalidated hole.
  • Constraints are opt-in per key — only the 24 recall keys declare bounds this wave, so every other key keeps exactly today's pass-through behaviour. No stored value becomes un-writable.

Nothing was removed from the settings surface: requirement was "no setting lost — only abstracted, gated, or managed", and coverage is now a test, not a promise.

Verification

  • 1240 backend tests, incl. under the sterile-config runner (nothing reads live config)
  • 454 client tests · tsc clean · ruff clean · pyright at baseline 0
  • task docs:check green with zero warnings
  • Live: Overview lists 25 changed-from-default settings grouped by section; Recall renders per-technique knobs with full help; manifest v2 serves constraints/tiers/help; every hazard fix confirmed against the running API

Deliberately out of scope (Wave 2)

Per-setting search (today's search still matches 21 section labels and hand-typed keyword arrays, not the ~200 settings), jump-to-control, deep links, and palette section commands. This wave leaves only the anchors they need. Tracked in todo/backlog/genome-advisor.md.

Decision recorded as ADR-17.

Assisted-by: Opus 5

…manifest

The config write surface lived in four hand-synced places: config_update's
per-section key tuples, settings_manifest's _CONFIG_WRITE_ROUTES mirror
(carrying a "keep in lockstep" comment), and prose in OpenApi.yaml and
endpoints.md. They had drifted, invisibly and in both directions:

  - `research` and `web_research` were writable but reported read-only,
    so the manifest under-reported 9 keys.
  - `ambassador` claimed `speech_model`/`voice`/`transcription_model` were
    writable while the handler's tuple omitted them and dropped every save
    — the same shape as the Images regression this repo already ate once.
  - `search.source_policy` advertised per-leaf writability, so a partial
    patch of `trusted` silently wiped `blocked` and `goggle`.

settings_registry.py is now the one declaration. It records deltas only —
type and default still come from DEFAULT_CONFIG and the memory kit's
pydantic Settings — so a section declares just what a generic walker
cannot infer: which keys are writable, which accept an explicit null,
which dict leaves are written whole, and the presentation axes. A key that
isn't declared is read-only; it can't be silently writable.

Writes are two-phase. plan_config_update validates the whole payload and
returns the ops it would apply; apply_ops mutates. A payload rejected
halfway (models.roles has always 400'd on a malformed value) no longer
leaves the process-global ConfigManager half-written until the next
reload. Sections whose accept-set is computed per request (providers) or
that carry a verb no other section has (context_limits) declare a planner
instead of a key list, so the write surface stays in one file even where
it can't be a static list.

The retired trajectory-compression bridge is removed. Those keys were
written through to data/config.json from /api/memory/settings with no
validation (pydantic's extra="ignore" waved them past the schema) and
against a second copy of the defaults that could drift from
DEFAULT_CONFIG. The client stopped writing them releases ago; the
canonical route is /api/config/update. Sending one now gets a 400 naming
where it moved, rather than a silent drop.

Manifest v2 adds constraints, tier, ui_section, and authored help, each
emitted only where declared so v1 consumers see no new noise. Help itself
is authored once in settings_help.yaml (summary / what / how / why /
manage) — the settings UI and the generated reference will both read it,
so they cannot disagree. Recall is authored in full as the golden section;
the rest ship registry-only for now.

Constraints are opt-in per key: only keys that declare a range validate on
write, so every existing stored value and every undeclared key keeps
exactly today's pass-through behaviour.

Also unified the three divergent copies of secret classification (the
config redactor, the manifest, and the subtree list) onto one definition,
and gave the display-only consolidation keys a single source so the
manifest and the POST handler agree they aren't writable.

Tests: the old lockstep guard scraped `_SEARCH_KEYS` out of views.py as
source text and compared it to the manifest's mirror — a guard for a
lockstep that no longer exists. Replaced with structural coverage that
asserts the property for every section at once: every declared writable
key round-trips through the endpoint, manifest writability equals registry
derivation, a rejected payload leaves config untouched, whole-write leaves
stay atomic, declared ranges reject and undeclared keys accept anything,
and the golden section's help is complete with no orphaned entries.

1239 backend tests pass, including under the sterile-config runner.

Assisted-by: Opus 5
…w, boundaries

The settings surface knew nothing about the settings it renders. Defaults were
re-typed as `?? 50` literals at 131 call sites, so a control could disagree
with the server about its own default and nothing would notice. Nothing showed
what you'd changed. Help was a `hint` string with nowhere to put the rest.

The client now reads GET /api/settings/manifest once per open and hands each
control its own entry. Three things follow from that.

**Field chrome.** A control bound to its manifest entry gains a dot when it
differs from the shipped default, a reset that puts it back, its declared
bounds, and a help popover carrying the authored prose — what it is, how it
works, when to change it, what to watch. All of it is opt-in: pass no binding
and the field renders exactly as before, so the sections that haven't been
migrated are untouched. Reset rides the normal autosave path (it's just an
update to the default value), so there's no second write path to keep honest.

**Accessibility.** Five of the eight primitives rendered a <Label> with no
htmlFor beside a control with no id, so the label announced nothing.
NumberField was worse: its accessible name resolved to `title` — the hint
sentence rather than the setting's name, which is what a screen reader would
read out for "Max Results". FieldShell now owns the id and hands it to the
control, and hints are wired through aria-describedby so both get announced.
SliderField deliberately keeps its aria-label instead: Radix puts role="slider"
on the thumb, so a `for` pointing at the root would just move the orphaned
label somewhere new.

**Overview.** Settings opened on Model Providers — an API-key admin page as the
answer to "where do I start". It now opens on Overview: what you've changed
from the defaults, grouped by the section that owns it, and tiles for
everything else. The digest is computed from the manifest rather than curated,
so it can't fall behind. Secrets are excluded — their values arrive redacted,
so "changed" is unanswerable for them and printing the comparison would be
worse than saying nothing. Empty strings read as "empty", never "default":
several model keys ship with the literal default `inherit`, and "inherit →
default" would mean the opposite of what it says. Settings with no owning
section say so rather than pretending to be clickable.

Rows navigate to the section. Landing on the exact control — scroll, focus,
flash — needs anchors this doesn't invent yet; that's the next wave.

**Boundaries.** Each section renders inside its own error boundary, keyed by
section id. A section that threw used to escape to the page-level boundary and
blank the app — which is precisely what Web Search did when it hit a Select it
couldn't render. Now the failure stays in the pane, the rest of settings keeps
working, and switching sections clears it. Pending edits survive the unmount
because useSettingsAutosave already flushes there.

Also fixed the dead `'servers'` default in useSettingsNavigation, which named a
section that stopped existing several reorgs ago and only ever worked because
every call site passed something else.

Verified against a live server: the manifest reports 331 entries, and Overview
correctly finds all 24 settings this install has moved off their defaults,
grouped across 12 sections plus the set-elsewhere group. 448 client tests pass.

Assisted-by: Opus 5
Recall is the most bespoke surface in Settings, so it goes first and sets the
pattern the other sections follow.

The panel was organised by *when a setting was added* rather than by what it
belongs to. HyDE's model and temperature sat under "HyDE Settings" while its
token budget sat three sections away in "Advanced"; Self-Query was split the
same way, with its temperature and token budget separated from its model. Eight
sections, and tuning one technique meant hunting through several of them.

Now each technique's toggle owns every knob it governs, indented beneath it and
shown only when it's on. Eight sections become three: Retrieval Techniques,
Two-Stage Rerank, and an Advanced disclosure holding the genuinely cross-cutting
knobs (min confidence, RRF k, graph depth, the first-person guard) collapsed by
default. Nothing was removed — every setting is still present and still
writable, and the collapsed ones are one click away.

All 24 keys now bind to the settings manifest, which kills the 25 `?? 50`
literals that re-stated server defaults at the call site. Each control gets its
declared bounds, a dot when it differs from the shipped default, a reset that
writes that default back through the normal autosave path, and a help popover
carrying the authored prose — what it is, how it works, when to change it, what
it interacts with. The failure state gained the retry button it never had.

Also fixed a real defect the manifest surfaced: `is_secret_path` matched its
markers as substrings, so "token" inside `max_tokens` classified 24 settings as
credentials — every token budget on the platform. Their defaults came back
`***`, which the settings UI and the generated reference would both have shown
where a number belongs. Markers now match whole words, with an `endswith` arm
so unseparated names like `apikey` stay covered; over-redacting a non-secret is
fine, leaking one is not.

Verified live: 22 help triggers on the panel, 6 technique groups, Advanced
collapsed on load (aria-expanded=false, height 0), and help rendering the full
five-field treatment. Settings now opens on Overview listing this install's 24
changed settings. 454 client tests pass.

Assisted-by: Opus 5
Per-setting documentation didn't exist. `configuration.md` showed one
illustrative `config.json` blob covering four of the roughly 180 keys,
with no types, no ranges, and no descriptions — everything added since
(search, context, alloy, ambassador, images, reasoning, compression) was
simply absent. What explanation there was lived as `hint` props in JSX,
which is to say it lived nowhere a reader could find it.

scripts/gen_settings_reference.py renders the whole catalogue from the
declarations the app itself reads: settings_help.yaml for prose,
settings_registry.py for bounds and tiers, DEFAULT_CONFIG and the memory
kit's Settings for types and defaults. Nothing on the page is authored by
the page, so the documentation and the settings screen say the same thing
by construction rather than by discipline.

The generator is deliberately pure — it imports declarations and never
calls get_config_manager() or get_settings(). Reading live config would
bake one machine's values into a committed file and make the output depend
on whose laptop ran it. settings_manifest grows build_reference_entries()
for exactly this: the same entries as the manifest, minus `value`.

Wired into `task docs:check` (and so into check:fast, release:check, and
CI) with the severity convention the other generated artifact uses: a page
that has drifted warns and tells you to regenerate; a page that is missing
errors. A stale generated file should degrade, never lie.

Settings that carry authored help get the long form — what it is, how it
works, when to change it, what to watch. Recall is written up in full as
the golden section; everything else is tabulated with its type, default,
and write route, and gets written up on the section cadence.

Also gave ui_section ids human labels (`Memory → Recall`, not
`memory-recall`), since the headings are read by people.

Assisted-by: Opus 5
Records the decision the last four commits implement, and brings the
hand-written contract docs in line with it.

ADR-17 states the rule: one declaration drives the config write path, the
manifest, and the generated reference; writes validate-all-then-apply-all;
constraints are opt-in per key (the zero-friction invariant — no stored
value becomes un-writable); help is authored once. It also records what
was rejected and why: `Field()` on the 150 pydantic fields (would add
validation nobody asked for and put UI governance in runtime kit code),
and keeping the trajectory bridge (a second blessed write path
contradicts the whole point).

OpenApi and endpoints.md stop enumerating the allowlists by hand. Those
paragraphs were two of the four hand-synced copies the registry exists to
collapse, and they had drifted like the rest — they now point at the
manifest and the generated reference, and document the behaviour that
actually matters (undeclared keys ignored, atomic rejection, `""` as a
meaningful "follow the role" value). The memory-settings docs record the
retired bridge and the display-only keys.

Also: `aria-current` on the active settings-nav item, so the section you're
on is announced as current rather than merely highlighted.

CLAUDE.md gains the "declare it, don't hand-write a handler" rule. It was
17 bytes under its ceiling, so several entries got tightened to make room
— the ratchet is meant to go down, not up.

Verified: 1240 backend tests, 454 client tests, tsc clean, ruff clean,
pyright at baseline 0, `task docs:check` green with no warnings.
Live: settings open on Overview listing 25 changed-from-default settings
grouped by section; Recall renders each technique's knobs beneath it with
the full help popover; manifest v2 serves constraints, tiers and help.

Assisted-by: Opus 5
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agentx-docs-site Ready Ready Preview Aug 6, 2026 11:31pm

@QR-Madness
QR-Madness merged commit eacd1f2 into master Aug 6, 2026
4 checks passed
@QR-Madness
QR-Madness deleted the feat/genome-foundation-settings branch August 6, 2026 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant