# 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`: ```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 let `Open()` 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 typed `AppConfig` struct (`SessionDuration time.Duration`). `main.go` calls it after `store.Open` and wires the result into `api.SessionConfig.Duration`. - Writes validate against the registry: unknown keys and invalid values are rejected, so the `config` table 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` → ```json { "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.go` builds the display-safe environment snapshot once (from `config.Config`, masking applied) and passes it to `api.NewServer` — handlers never call `os.Getenv`. - `PUT /api/config` with `{"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.tsx` derives the view from `window.location.pathname` (no router library; `history.pushState` on 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: 1. Profile button: icon-only, user-profile glyph (👤). The profile name moves into `title`/`aria-label` — no visible text. 2. New settings button: icon-only gear (⚙), navigates to `/config`. 3. Log out: icon-only cross (✕), still the POST `
` (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. ## 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`, edit `session.duration`, save, verify persisted via GET and after restart.