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_configseeds the in-memory dict (use this in tests).await cfg.load(pool) -> int— fetch all non-secret rows fromapp_settingsinto 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 bothenc:v1:...encrypted and legacy plaintext rows transparently viaplugins.secrets.get_secret.cfg.require(key) -> str— sync. RaisesRuntimeErrorif 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— acceptstrue,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 viaget()since the last drain, then clears the set. The read-telemetry sink, called byFlushSettingsReadTelemetryJob(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 anyget()/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 BEFORESiteConfigexists. Resolved bybrain.bootstrap.resolve_database_url.
Dependencies
- Reads from:
app_settingstable (one query at startup, one perget_secret()call). - Writes to: no DB writes — read-only by design; settings are
mutated via
services.settings_service.SettingsServiceor the/api/settingsroute. (get()does record each read key in an in-memory set for telemetry; the separateFlushSettingsReadTelemetryJobperforms thelast_read_atUPDATE. 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, returns0. Subsequentget()calls will only hit env vars + defaults. This is the pre-startup bootstrap state. load()query fails — caught, logged as warning, returns0. Cache stays empty. Recover by callingreload(pool)once the DB is back.require()on unset key — raisesRuntimeError("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_secretreturns 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 listorSELECT 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 useservices.bootstrap_defaults.ensure_defaults); read it viacfg.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 useget_secret()instead ofget(). The cache will skip it on nextreload(). - Test seam: use the
default_container_activefixture orSiteConfig(initial_config={"site_url": "https://test"})passed via constructor DI. The module-level singleton andset_site_configfan-out are retired; do not add newset_site_configcalls. - Find the env var equivalent: any
cfg.get("foo_bar")falls back toFOO_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 theSiteConfigset viadrain_read_keys(), unions it with the process-wideSettingsServiceread sink (services.settings_read_sink, poindexter#756), and batch-stampsapp_settings.last_read_at = NOW()for those keys. The UPDATE only touches rows whoselast_read_atis NULL or older thansettings_read_telemetry_min_restamp_seconds(default 3600), so a hot key is written ~once/hour rather than 60×. Gated bysettings_read_telemetry_enabled(default true).ProbeZeroReaderSettingsJob(services/jobs/probe_zero_reader_settings.py, every 6h) is the inverse query: non-secret, non-deprecated keys whoselast_read_atis still NULL more thansettings_zero_reader_grace_days(default 30) days aftercreated_atare emitted as one advisorysettings_zero_reader_keysfinding (severitywarn, stablededup_key) routed to Discord ops viafindings.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”).
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 takessite_configin__init__and reads its limits via_limit()helper.services.settings_service.SettingsService— the write path (mutates settings; logs toaudit_logfor the change history).feedback_no_env_vars(operator design note) andfeedback_db_first_config.md— why this exists.