Design for the 'configuration' backlog idea: instance-global key/value config table (overrides only, cold reload), env-var renames, /api/config GET/PUT, and the /config page with header icon buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.8 KiB
5.8 KiB
Configuration: application vs environment — design
Date: 2026-08-03 Status: approved
Problem
All runtime tuning today is either an env var (internal/config, process-level)
or a per-user profile column. There is no home for instance-global,
behavior-affecting settings shared by all users, and some env vars are
misnamed or shouldn't be env vars at all. From docs/IDEAS.md ("configuration").
Concepts
- Application configuration: key/value pairs, stored in the database, common to all users, cold (read once at startup; a change takes effect on the next backend restart — no hot reload). Unrelated to the execution environment.
- Environment configuration: env vars, tied to the execution environment (paths, URLs, credentials for infrastructure). Unchanged mechanism.
Data model
New table in schema.sql:
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
- Deliberately not user-scoped — the single exception to the per-user convention, because application configuration is instance-global by definition. A schema comment records this.
- The table stores overrides only; defaults live in code. Absent key = default in effect.
- Regenerate
docs/DATABASE.md(go run ./cmd/dumpschema) after the schema change. Breaking schema change: delete the local DB and letOpen()recreate it (normal pre-production remedy).
Application-config registry
internal/config gains a registry of known keys — name, default, validator,
description. Launch set (one key):
| key | type | default | validation |
|---|---|---|---|
session.duration |
integer hours | 720 |
integer, > 0 |
config.Load()(env) stays as-is: fail-fast, runs before the DB opens.- New
config.LoadApp(getter)reads overrides through a small store-backed getter, merges defaults, validates, and returns a typedAppConfigstruct (SessionDuration time.Duration).main.gocalls it afterstore.Openand wires the result intoapi.SessionConfig.Duration. - Writes validate against the registry: unknown keys and invalid values are
rejected, so the
configtable cannot accumulate junk.
Environment-variable changes (all breaking; pre-production, no shims)
| before | after |
|---|---|
GENIUSRUN_ADDR |
GENIUSRUN_BACKEND_ADDR |
GARMIN_WRAPPER_PYTHON |
GENIUSRUN_PYTHON_PATH |
GARMIN_TOKENSTORE |
GENIUSRUN_TOKENSTORE_PATH |
GENIUSRUN_MIN_CONFIDENCE |
removed — hard-coded to classify.DefaultMinConfidence (0.6) |
GENIUSRUN_SESSION_DURATION |
removed — becomes app-config key session.duration |
GENIUSRUN_SESSION_SECRET |
unchanged (stays env; still required, >= 32 chars) |
Also update: backend/.env (local), CLAUDE.md, config tests, and any doc
references.
API
Both routes sit behind the usual session + requireProvisionedUser gates.
Any provisioned user may read and write (single-operator app in practice).
GET /api/config→{ "application": [ {"key": "session.duration", "value": "720", "default": "720", "overridden": false, "description": "Session cookie lifetime in hours"} ], "environment": [ {"name": "GENIUSRUN_BACKEND_ADDR", "value": ":8080"}, {"name": "GENIUSRUN_OIDC_CLIENT_SECRET", "value": "•••• (set)"} ] }- Secrets (
GENIUSRUN_OIDC_CLIENT_SECRET,GENIUSRUN_SESSION_SECRET) are masked as•••• (set)/(unset); values never leave the process. main.gobuilds the display-safe environment snapshot once (fromconfig.Config, masking applied) and passes it toapi.NewServer— handlers never callos.Getenv.
- Secrets (
PUT /api/configwith{"session.duration": "168"}: validates every pair against the registry (reject unknown key / invalid value with 400 naming the offender; all-or-nothing), upserts, returns the same shape as GET. Cold semantics: response carries no magic — the UI states that changes apply after a backend restart.
Frontend
- Config page at browser path
/config—App.tsxderives the view fromwindow.location.pathname(no router library;history.pushStateon navigation so the URL is shareable/deep-linkable, and Vite's SPA fallback serves it in dev; a production reverse proxy needs SPA fallback for the path). - Page content: an "Application configuration" card (editable fields per registry entry, Save button, a visible note "changes take effect after the backend restarts"), and a read-only "Environment configuration" card listing name/value pairs, secrets masked by the backend.
- Header changes (
App.tsx), left to right after the tabs:- Profile button: icon-only, user-profile glyph (👤). The profile name
moves into
title/aria-label— no visible text. - New settings button: icon-only gear (⚙), navigates to
/config. - Log out: icon-only cross (✕), still the POST
<form>(OIDC logout needs a real navigation).title/aria-label"Log out".
- No visible text labels on any of the three; same emoji/glyph style as the existing tabs, no icon library added.
- Profile button: icon-only, user-profile glyph (👤). The profile name
moves into
Testing
internal/store: config upsert/read round-trip; overrides-only semantics.internal/config: table-driven registry tests — default when absent, override applied, invalid value rejected, unknown key rejected.internal/api: httptest — GET shape + secret masking; PUT happy path, unknown key 400, invalid value 400; 403 until provisioned (inherited gate, asserted once).- Frontend: live click-through — header icons, navigate to
/config, editsession.duration, save, verify persisted via GET and after restart.