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:
132
docs/superpowers/specs/2026-08-03-configuration-design.md
Normal file
132
docs/superpowers/specs/2026-08-03-configuration-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user