Files
ImageApi/docs/adr/0002-owner-defaults-vs-env.md
T
Cameron Cordes e84ac82f86 ADR-0002: owner defaults live in the DB row; .env keeps deployment config and secrets
Records the config boundary the #127 owner-default columns establish:
.env = boot-time deployment config + secrets (never DB), user_ai_prefs
single row = per-request owner defaults (hot-tunable, no secrets),
resolution request param -> owner default -> hard default, and the
safe-by-default privacy posture for default_backend. Adds the
Configuration glossary section to CONTEXT.md (owner default,
deployment config, hard default).
2026-09-27 22:50:28 -04:00

4.8 KiB

ADR-0002: Owner defaults live in the DB row; .env keeps deployment config and secrets

Status: accepted (2026-09-27)

Context

All configuration lived in .env: bind address, media paths, Ollama/llama-swap/OpenRouter URLs, model names, LLM_BACKEND, the embedding contract, TTS voices. Two friction points: values the owner actually tunes (which model, which backend, which persona, how long a reel is) needed an edit + redeploy to change, and clients had no server-side fallback — "use the default" was expressed by omitting a field, and the default itself was invisible and untouchable at runtime. The user_ai_prefs single row (id = 1) already existed as a passive mirror of the last client reel request for the nightly pre-gen. #126/#127 gave it an explicit PUT /prefs surface and five owner-default columns (default_model, default_backend, default_persona_id, reel_span, reel_max_segments) — the first batch of non-private configuration moved out of .env and into the database.

Decision

Configuration splits into two homes by one rule — does the server need the value before it can start serving?

  • .env (deployment config): everything read at boot to bind, connect, or open — BIND_URL, DATABASE_URL, media paths, OLLAMA_*_URL / LLAMA_SWAP_URL, LLM_BACKEND, the embedding contract — and all secrets (OPENROUTER_API_KEY, SECRET_KEY, SMS_API_TOKEN). Secrets never go in the database: the SQLite file gets backed up, copied, and attached to bug reports.
  • user_ai_prefs (owner defaults): per-request defaults resolved at request time — the model/backend/persona a client gets when it omits the field, reel span and segment cap, display zone, TTS voice. Hot-tunable via PUT /prefs, effective on the next request, no redeploy. This row is the designated home for future non-private, per-request configuration that outgrows .env. The table is a single row per deployment, not a per-user profile — user_ai_prefs is a historical name; the canonical term is owner defaults (single-user tool: owner = admin).

Resolution order at every consumer: explicit request param → stored owner default → hard default (the env/code fallback). An explicit request always wins, so any client can override the owner's default per request. The env value stays the floor under the DB: a NULL default_model means "whatever the Ollama/llama-swap primary is", so env changes keep working.

Privacy posture is safe-by-default. default_backend is the one owner default with network-egress consequences: hybrid routes insight generation and chat to OpenRouter. With no row or a NULL column, the hard default is local — data stays on the LAN. Leaving the network requires an explicit owner action (storing hybrid), and any client can still pin itself by sending backend explicitly. Owner defaults therefore split into two sensitivity classes: harmless tuning (span, segments, display zone, voice, model, persona) and privacy/security (default_backend), which any admin UI must surface with a warning, not a quiet dropdown.

Considered options

  • Everything in .env (status quo): safe, but tuning a model default means edit + redeploy, and clients have no server-side default to fall back to — a picker's "server default" is just the env value, invisible and unchangeable at runtime. Rejected for per-request defaults.
  • Everything in the DB: boot-time values (bind address, DB path, the URLs used to build the HTTP clients) become chicken-and-egg, and secrets sit next to tuning values in a file that gets copied around. Rejected.
  • A separate system_config table: cleaner name, but a second single-row table would be a synonym with extra migrations; the row and the PUT /prefs surface already exist. Rejected — the row keeps its name, its term becomes owner defaults.

Non-obvious rules worth remembering

  • Owner defaults are global to the deployment. Every client that omits a field gets the stored value, on every device. Storing default_backend=hybrid changes the mobile app's behavior even for users who never touch the setting; a client that must stay local should send backend explicitly (tracked for FileViewer-React #33).
  • A stored value that is no longer consumable fails at write time, not request time — PUT /prefs validates default_backend against the canonical BackendKind::parse and reel_span against the span enum before writing, so a bad default cannot 500 every request later.
  • The passive reel mirror (capture_prefs) may only write the fields it owns (voice, library, display zone, effective span/segments); it carries the AI owner-defaults through unchanged, because the upsert overwrites every column.
  • NULL means "use the hard default", not "unset the env" — the DB layer sits above env, never instead of it.