docs: spec for application vs environment configuration

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>
This commit is contained in:
2026-08-03 23:47:57 +02:00
parent c40f6634f2
commit eb24dcd89d
2 changed files with 191 additions and 8 deletions

View File

@@ -0,0 +1,132 @@
# 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 `<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.
## 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.