ADR-017 — Config via API
Date: 2026-06-17 Status: Accepted Related: 010-param-management · 013-trim-concentration-override
Context
ADR-010 classified parameters into three categories: tunable (tunable.py, following the default→YAML→CLI→env injection chain), locked (locked.py, requiring an ADR to change), and engineering parameters. However, "tunable" parameters could previously only be changed during deployment via env/yaml — GUI non-technical users and agents had no way to change configuration at runtime, violating the product philosophy of three-tier parity (anything the GUI can do, the agent can do, all via the same underlying Web API).
A typical pain point: users with single-asset / deliberately concentrated strategies were perpetually harassed by the committee for TRIM-for-concentration. The only workaround was to fill emergency_buffer_cny with a fake large number to trigger Sanity-4 (see 013) — conflating "I have a safety net" with "stop nagging me about concentration." The correct solution is a real toggle, and making "config via API" the general direction.
Decision
Rule 1: Config via API, env reserved for only two buckets
All non-secret, non-bootstrapping tunable behavior switches must be configurable at runtime via the API, persisted to disk and shared across processes. env is reserved for only two categories:
| Bucket | Example | Why env |
|---|---|---|
| Secrets | LLM_API_KEY / GITHUB_TOKEN / Gmail / INVEST_API_TOKEN / CF_ACCESS_* | Never in API responses / not persisted in config archive / not in logs |
| Bootstrapping | INVEST_API_BASE / INVEST_HOME / port / hub mode | Must decide "where to run / what to connect to" before the config system starts |
locked.py goes into neither env nor API (requires an ADR to change).
Rule 2: Persistent override is the highest priority in the injection chain (pure append, no reorder)
load_config() in core/config/_loader.py appends a persistent override layer after env:
default(dataclass) → YAML → CLI/set_config_override → env → [New] Persistent API override- Persisted to
memory/.state/config_overrides.json(viaMemoryStore.state_set/state_get, atomic write + fcntl). - Pure append, no change to the relative order of existing layers: when the archive is empty, behavior is byte-for-byte identical to history (zero regression).
- Priority: Persistent API override > env > yaml > default. Meaning "config via API, env degrades to bootstrap default."
load_config()is the common read point for all three processes (web / cron / skill) → changing one place automatically aligns all three paths, zero entry changes.
Rule 3: Only expose a whitelist (API_SETTABLE), reject everything else
core/config/_loader.py:API_SETTABLE is the sole list of keys readable/writable via the API, containing only "user-safe" behavior switches. Initial batch:
| key | Type | Meaning |
|---|---|---|
verdict.concentration_lens_enabled | bool (default True) | Off = single-asset/deliberately concentrated strategies are no longer advised to reduce position due to concentration (volatility/drawdown/stop-loss/valuation still apply). See 013 |
verdict.risk_profile | enum steady/aggressive (default steady) | aggressive = uptrend trend-following with leverage (high risk) |
verdict.gold_defense_dca_enabled | bool (default True) | Batch release of gold buys in high VIX/ATR zones vs. full block |
dreaming.llm_verify_enabled | bool (default False) | Run a cheap LLM check for spurious correlations before writing insights in Deep Sleep |
Internal sweep thresholds (alloc ceiling / confidence floor / regime thresholds / oracle / reward, etc.) are not in the whitelist (upholding ADR-010 rule 4: thresholds not validated by OOS should not be user knobs). Adding a new key requires explicitly adding a line to API_SETTABLE + writing type/label/help; PUT validates type/enum values against the spec.
Rule 4: Three-tier parity — same underlying layer
- Web API:
GET /api/config(effective whitelist values +overridden+ metadata),PUT /api/config(set one override),DELETE /api/config/{key}(delete override, fall back to default). Route inconnectors/web_api/routers/config.py, model inconnectors/web_api/models.py. - CLI/skill:
skill config/--set KEY VALUE/--clear KEY(scripts/skill_cmds/config_cmds.py). - GUI (invest-gui): "Settings → Committee Configuration" section reads/writes the same endpoint.
- All three call the same
core.config.{effective_api_config, set_persisted_override, clear_persisted_override}.
Evidence / Invariants
- Empty persistent archive →
load_config()behavior identical to history (full suite of 790 passed, includingtest_api_override_beats_envupholding "API > env" priority,test_env_applies_when_no_overrideupholding env as bootstrap default). - Old bare
os.getenvforINVEST_DREAMING_LLM_VERIFYis absorbed intodreaming.llm_verify_enabledvia_LEGACY_MAPfor backward compatibility. - PUT/DELETE on non-whitelist keys → 400/404; invalid enum values → 400.
Boundaries
- Does not change the exposure surface of
locked.py(still gated by ADR). - Caching:
load_config()has module-level caching; after an API write,reset_config()invalidates it, forcing a re-read on the next access within the same process; cross-process (cron/skill new processes) naturally reads the latest archive. - Remote hub mode: The archive resides in the hub's
memory/.state/; the client forwards requests via$INVEST_API_BASE, behavior is consistent.
Follow-up (2026-06-23, ADR-019)
When the concentration lens first launched, it coexisted with the solvency auto-bailout from ADR-013 (dual trigger for Sanity 4). On 2026-06-23, ADR-019 removed the solvency auto-trigger: whether concentration constitutes a constraint is now controlled solely by the explicit toggle verdict.concentration_lens_enabled. The lens becomes the single source of truth, perfectly aligning with this ADR's philosophy of "config via explicit API, env as bootstrap only."