Files
geniusrun/docs/superpowers/specs/2026-08-03-configuration-design.md
Christophe Vila eb24dcd89d 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>
2026-08-03 23:47:57 +02:00

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 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
    {
      "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 /configApp.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.