Skip to content

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:

BucketExampleWhy env
SecretsLLM_API_KEY / GITHUB_TOKEN / Gmail / INVEST_API_TOKEN / CF_ACCESS_*Never in API responses / not persisted in config archive / not in logs
BootstrappingINVEST_API_BASE / INVEST_HOME / port / hub modeMust 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 (via MemoryStore.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:

keyTypeMeaning
verdict.concentration_lens_enabledbool (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_profileenum steady/aggressive (default steady)aggressive = uptrend trend-following with leverage (high risk)
verdict.gold_defense_dca_enabledbool (default True)Batch release of gold buys in high VIX/ATR zones vs. full block
dreaming.llm_verify_enabledbool (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 in connectors/web_api/routers/config.py, model in connectors/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, including test_api_override_beats_env upholding "API > env" priority, test_env_applies_when_no_override upholding env as bootstrap default).
  • Old bare os.getenv for INVEST_DREAMING_LLM_VERIFY is absorbed into dreaming.llm_verify_enabled via _LEGACY_MAP for 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."