Skip to main content

Site Config

File: src/cofounder_agent/services/site_config.py Tested by: src/cofounder_agent/tests/unit/services/test_site_config.py Last reviewed: 2026-06-30

What it does

SiteConfig is the dependency-injectable seam over app_settings. Every service that used to call os.getenv() now reads through a SiteConfig instance: route handlers receive it via FastAPI Depends(get_site_config_dependency), services accept it in __init__, pipeline stages pull it from context["site_config"], plugins/taps/sources from config["_site_config"]. The instance is constructed once in main.py lifespan, populated from app_settings at startup, and attached to app.state.site_config. Non-secret settings are loaded into an in-memory dict at startup so get() is sync. Secrets (rows with is_secret=true) are deliberately NOT cached — get_secret() is async and hits the DB on every call so they don’t leak into debug dumps. Both flow through the same app_settings table; only the cache treatment differs. Module-level singleton and set_site_config fan-out both retired. The module-level site_config singleton was deleted 2026-05-09 (glad-labs-stack#330). The intermediate per-module set_site_config() / WIRED_MODULES fan-out pattern was retired by the #788 capstone — services/di_wiring.py::WIRED_MODULES is now an empty tuple. Do not add new set_site_config setters or rely on WIRED_MODULES; that seam is dead. The live composition root is AppContainer (services/container.py), built once per entry point by services.bootstrap.build_container. It holds the process-wide SiteConfig and exposes migrated services as cached_property accessors. A scheduled reload_site_config job refreshes the DB-loaded values every minute via site_config.reload(pool) — because AppContainer holds the same instance by reference, fresh DB values propagate to every service the container constructed. Tests construct their own SiteConfig(initial_config={...}) or use the default_container_active fixture from tests/unit/conftest.py, which registers a seeded SiteConfig on an AppContainer so container-accessor modules (prompt_manager, gpu_scheduler, …) see the brand seed.

Public API

  • SiteConfig(*, initial_config=None, pool=None) — constructor. initial_config seeds the in-memory dict (use this in tests).
  • await cfg.load(pool) -> int — fetch all non-secret rows from app_settings into the cache. Call once at startup. Returns count.
  • await cfg.reload(pool) -> int — atomic replace of the cache. Safe to call on a running app; useful after settings updates.
  • await cfg.get_secret(key, default="") -> str — async DB lookup for secret rows. Falls back to the uppercase env var, then default. Handles both enc:v1:... encrypted and legacy plaintext rows transparently via plugins.secrets.get_secret.
  • cfg.require(key) -> str — sync. Raises RuntimeError if the key isn’t in cache or env. Use for settings that MUST be set (site_url, company_name, etc.).
  • cfg.get(key, default="") -> str — sync. Priority: cache > env var (uppercase) > default.
  • cfg.get_int(key, default=0) -> int — coerces, falls back on ValueError/TypeError.
  • cfg.get_float(key, default=0.0) -> float — same.
  • cfg.get_bool(key, default=False) -> bool — accepts true, 1, yes, on (case-insensitive).
  • cfg.get_list(key, default="") -> list[str] — comma-separated.
  • cfg.is_loaded -> bool — property.
  • cfg.all() -> dict[str, str] — copy of the cache (debug only; excludes secrets by construction).
  • cfg.drain_read_keys() -> list[str] — returns the keys read via get() since the last drain, then clears the set. The read-telemetry sink, called by FlushSettingsReadTelemetryJob (see “Read telemetry & orphan detection” below).

Configuration

SiteConfig reads from app_settings; it doesn’t read its own settings. The set of keys depends entirely on what’s in the table — ~1,090 keys (~68 secret) as of 2026-06. See docs/reference/app-settings.md for the current inventory, or run poindexter settings list to query the table directly. The only env vars SiteConfig itself touches:
  • <KEY> (uppercase) — fallback for any get()/require() lookup that isn’t in the DB cache. This is for bootstrap-only settings; the codebase’s general direction is DB-first (#198).
  • DATABASE_URL / LOCAL_DATABASE_URL — chicken-and-egg, used to resolve the pool BEFORE SiteConfig exists. Resolved by brain.bootstrap.resolve_database_url.

Dependencies

  • Reads from: app_settings table (one query at startup, one per get_secret() call).
  • Writes to: no DB writes — read-only by design; settings are mutated via services.settings_service.SettingsService or the /api/settings route. (get() does record each read key in an in-memory set for telemetry; the separate FlushSettingsReadTelemetryJob performs the last_read_at UPDATE. See “Read telemetry & orphan detection” below.)
  • External APIs: none.

Failure modes

  • DB pool not provided / pool is None on load() — logs [SITE_CONFIG] No DB pool — using env var fallbacks only, returns 0. Subsequent get() calls will only hit env vars + defaults. This is the pre-startup bootstrap state.
  • load() query fails — caught, logged as warning, returns 0. Cache stays empty. Recover by calling reload(pool) once the DB is back.
  • require() on unset key — raises RuntimeError("Required setting '<key>' is not configured. Set it in app_settings table or as env var <KEY>."). This is the “fail loud” principle — no silent fallbacks for required settings.
  • get_secret() query fails — caught, logged as warning, falls through to env var + default. The caller can’t tell whether the key truly doesn’t exist or whether the lookup failed; check logs.
  • Secret stored as plaintext (legacy)plugins.secrets.get_secret returns the value as-is; it’s caller-transparent. Migration to encrypted-at-rest is tracked separately.

Common ops

  • Set a value via CLI: poindexter settings set <key> <value>.
  • List all settings: poindexter settings list or SELECT key, value, is_secret FROM app_settings ORDER BY key;
  • Reload after a manual DB edit: call await app.state.site_config.reload(app.state.pool) from a one-off script, or restart the app.
  • Add a new key with default: insert into app_settings (or use services.bootstrap_defaults.ensure_defaults); read it via cfg.get("<key>", "<default>") in code so tests without the DB row still work.
  • Mark a key as secret: UPDATE app_settings SET is_secret = true WHERE key = '<key>'; — then update callers to use get_secret() instead of get(). The cache will skip it on next reload().
  • Test seam: use the default_container_active fixture or SiteConfig(initial_config={"site_url": "https://test"}) passed via constructor DI. The module-level singleton and set_site_config fan-out are retired; do not add new set_site_config calls.
  • Find the env var equivalent: any cfg.get("foo_bar") falls back to FOO_BAR. Use sparingly — DB-first is the policy.

Read telemetry & orphan detection (#756)

get() records every key it is asked for into a per-instance in-memory set — an O(1) set.add on the hot path. It writes nothing itself, and load()/reload() deliberately do NOT mark keys read, so a 60s cache refresh never makes every key look consumed. Two scheduled jobs close the loop (both reach the lifespan-bound SiteConfig via config["_site_config"], seeded into every job by the plugin scheduler):
  • FlushSettingsReadTelemetryJob (services/jobs/flush_settings_read_telemetry.py, every minute) drains the SiteConfig set via drain_read_keys(), unions it with the process-wide SettingsService read sink (services.settings_read_sink, poindexter#756), and batch-stamps app_settings.last_read_at = NOW() for those keys. The UPDATE only touches rows whose last_read_at is NULL or older than settings_read_telemetry_min_restamp_seconds (default 3600), so a hot key is written ~once/hour rather than 60×. Gated by settings_read_telemetry_enabled (default true).
  • ProbeZeroReaderSettingsJob (services/jobs/probe_zero_reader_settings.py, every 6h) is the inverse query: non-secret, non-deprecated keys whose last_read_at is still NULL more than settings_zero_reader_grace_days (default 30) days after created_at are emitted as one advisory settings_zero_reader_keys finding (severity warn, stable dedup_key) routed to Discord ops via findings.settings_zero_reader_keys.delivery. The grace window self- suppresses on fresh installs and gives newly-seeded keys time to be read. The live list also renders on the Integrations & Admin Grafana board (“Settings Lifecycle — Orphan Candidates”).
Advisory, not authoritative. last_read_at is stamped on both in-process read accessors — SiteConfig.get (per-instance set) and SettingsService.get (the shared services.settings_read_sink, which the flush job unions in; poindexter#756). A key read EXCLUSIVELY via a path neither accessor covers still surfaces as an orphan candidate: raw SQL (e.g. findings_alert_router reading findings.* policies, auto_publish reading auto_publish_threshold) or the brain daemon’s own asyncpg reads (a separate process, so its reads never reach this worker’s flush job). Verify each key before retiring it.

See also

  • CLAUDE.md “Configuration (#198 — no hardcoded values in code)” — full explanation of the DI seam, deprecated singleton, and how callers should plumb the instance.
  • docs/architecture/services/cost_guard.md — example of a service that takes site_config in __init__ and reads its limits via _limit() helper.
  • services.settings_service.SettingsService — the write path (mutates settings; logs to audit_log for the change history).
  • feedback_no_env_vars (operator design note) and feedback_db_first_config.md — why this exists.