Site Config
File:src/cofounder_agent/poindexter/services/site_config.py
Tested by: src/cofounder_agent/tests/unit/services/test_site_config.py
Last reviewed: 2026-09-28
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 worker’s
instance is constructed once in main.py, loaded from app_settings by
the lifespan’s StartupManager right after migrations (before any
startup step reads a setting), and shared as
app.state.container.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, read_recorder=None)— constructor.initial_configseeds the in-memory dict (use this in tests).read_recorderis whereget()/require()record the keys they read; it defaults to the instance’s own set. The CLI passessettings_read_sink.record_read(see “Read telemetry” below).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.). Records the read, likeget().cfg.get(key, default="") -> str— sync. Priority: cache > env var (uppercase) > default. Records the read for telemetry.cfg.peek(key, default="") -> str— resolves exactly likeget()but records nothing and skips the deprecation warning. For settings-admin surfaces that fetch a key someone named (the console chat’sget_settingtool); code that uses a value callsget().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()/require()since the last drain, then clears the set (always empty with aread_recorder). The read-telemetry buffer, drained byservices.settings_read_telemetry.flush_read_telemetry(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,242 keys (~68 secret) on prod as of 2026-07. See
docs/reference/app-settings.md
for the current inventory, or run
poindexter settings list to query the table directly.
Who seeds app_settings (fresh install)
Three sources seed the table. The baseline and DEFAULTS insert with
ON CONFLICT (key) DO NOTHING, so first writer wins. The brain seed also
refills any empty value, since seed_loader reads '' as unconfigured. Which
one wins depends on the install path:
On
docker compose up against an empty DB the brain seeds first (worker
declares depends_on: brain-daemon: service_healthy) and creates app_settings
itself. Until 2026-09-28 that order never completed (poindexter#1097). The brain
created 8 of the table’s 14 columns, the baseline’s CREATE TABLE IF NOT EXISTS
kept that narrower table, and the baseline then failed on
idx_app_settings_is_active, so the worker restart-looped on every
compose-first install. The brain now creates the table the baseline declares,
and the baseline widens one an older brain left behind. Both orders end with the
same schema and the same keys (migrations-smoke runs both and compares them).
They differ only in the free-tier caps and quality bars the value-drift lint’s
TIER_POLICY lists (daily_post_limit, qa_final_score_threshold, …), where
the brain’s value lands first and stays, giving the precedence
brain > baseline > DEFAULTS. Via poindexter setup, migrations plus
seed_all_defaults run before any container, so the order is
baseline > DEFAULTS. The brain seed does not no-op when the stack then comes
up. It inserts its brain-only keys and refills every empty value, so identity
keys the reference seed leaves empty (site_name, company_name, …) hold its
runnable placeholders on both paths. Either way, for a key the baseline also
seeds, the DEFAULTS value is only reachable if it matches the baseline.
settings_defaults.py and 0000_baseline.seeds.sql must therefore agree on
every overlapping key; poindexter/brain/seed_app_settings.json may differ only via the
declared TIER_POLICY allowlist. All three are held consistent by
scripts/ci/settings_seed_value_drift_lint.py (in the migrations-smoke
check).
The operator overlay (settings_defaults.apply_operator_overrides, a no-op on
OSS installs) restores the operator’s values over any row still holding a
seeded value. Its guard accepts DEFAULTS[key] or the brain seed’s value, and
never a value tuned at runtime.
A sibling gate, scripts/ci/settings_phantom_read_lint.py (in test-backend),
catches the mirror-image bug: a literal app_settings key read in
production code that none of the three sources above defines — a fresh
install has no row for it, so the read silently falls back to whatever
default is baked into the call site, forever, and no gate can see the gap.
Motivating case: gpu_scheduler.py read electricity_rate_kwh_usd, which
nothing seeds, while the real, EIA-maintained electricity_rate_kwh sat
unread (glad-labs-stack#4065). It is the inverse of
ProbeZeroReaderSettingsJob (a key that exists but is never read) — this one
finds a key that is read but never exists. Secrets are exempt structurally
(read exclusively via .get_secret()); everything else unseeded on purpose
(a legacy-key fallback, an OSS-privacy redaction, a bootstrap credential)
needs a reasoned entry in that lint’s own ALLOWLIST. A ratchet, like the
value-drift lint above: existing gaps are grandfathered, only a net-new one
fails CI. It sees literal keys only: a key assembled at the call site
(research_quality_service.py’s _weight() reads f"research_{key}_weight")
is invisible to it by construction, so a dynamically keyed tunable has to be
seeded by hand, with a unit test that ties each seeded key to the read it backs
(see TestScoringWeightSettings).
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;services/settings_read_telemetry.pyperforms thelast_read_atUPDATE, called by every process that reads settings: the worker once a minute, and each content-flow run, CLI command and auto-embed pass as it finishes. 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.- A read before
load()— resolves from env vars and code defaults, yetget()still records the key forlast_read_at. The stored value looks used and isn’t. The worker’sStartupManager._load_site_configruns right after migrations, ahead of every startup step that reads a setting. Until 2026-09-28 the load ran inmain.py’s lifespan after all of startup, so every startup read hit this: a storedenable_image_gen_warmup='true'did nothing for three months, and the Ollama model validator never sawollama_model_validation_skip_keys. When a stored setting seems to have no effect, check where it is read relative to the load. 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() and require() record every key they are asked for into a
per-instance in-memory set — an O(1) set.add on the hot path. They write
nothing themselves, and load()/reload() deliberately do NOT mark keys
read, so a 60s cache refresh never makes every key look consumed.
What counts as a read
The probe below asks one question: does the running system consult this key? So a read counts when code asks for a key it names, and only then:- Recorded:
SiteConfig.get/get_int/get_float/get_bool/get_listandrequire(into the instance’s set), plusSettingsService.get,DatabaseService.get_setting_valueand the raw-SQL helpers that callrecord_read(into the process-wideservices.settings_read_sink). - Not recorded: the settings-admin surfaces, which fetch a key because
a person or an agent named it:
GET/POST/PUT /api/settings/{key}(the row fetch inAdminDatabase.get_setting, behindpoindexter settings get, the console’s settings editor and the MCPset_settingtool), the MCPget_settingtool (the same row fetch) and the console chat’sget_settingtool (SiteConfig.peek). A lookup says nothing about whether the system uses a key. - Not recorded either: bulk reads (
get_all_settings,SettingsService.get_all/get_by_category,SiteConfig.all()) andget_secret(). Secrets are outside the probe, and eachget_secret()call queries the database anyway.
last_read_at is NULL, and nothing
clears a stamp. So poindexter settings set <key> … followed by the usual
poindexter settings get <key> hid the key from the probe for good. On
prod, 11 of 742 stamped keys carried exactly that signature, stamped 6-68 s
after a value edit and never again. Loki still held three of those edits
(ragas_enabled, compose_drift_on_demand_services,
persona.presenter.portrait_url) and shows the host’s GET /api/settings/<key> just before each stamp. Migration
20260928_141858_clear_last_read_at_stamps_left_by_settings_admin_lookups
clears stamps with that signature: within 5 minutes after the last value
edit and unmoved for a day.
Flushing: every process flushes its own reads
The read set and the sink are process memory, so a read is stamped only if the process that made it flushes. The drain-and-stamp step isservices/settings_read_telemetry.py::flush_read_telemetry(pool, site_config=None).
It drains the SiteConfig set via drain_read_keys(), unions it with
the process-wide sink, 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 about once an hour however many
processes read it. Gated by settings_read_telemetry_enabled (default
true), which governs every caller. Called with no SiteConfig, it drains
only the sink and reads those two controls from app_settings on the
connection that runs the UPDATE. A failed stamp is logged and reported,
never raised.
- The worker.
FlushSettingsReadTelemetryJob(services/jobs/flush_settings_read_telemetry.py, every minute) flushes the lifespan-boundSiteConfig, which the plugin scheduler seeds into every job asconfig["_site_config"]. - Each Prefect content-flow run.
content_generation_flow(services/flows/content_generation.py) flushes the run’s ownSiteConfigfrom itsfinally, before the run’s DB pool closes, on a crashed run as well as a clean one. The Prefect worker runs every flow run in a fresh subprocess (prefect worker start --type process) that builds its ownSiteConfigand exits when the run ends; nothing else can reach that subprocess’s buffers. Until 2026-09-28 the flow didn’t flush, so no read made inside the pipeline was ever stamped.content_flow_stale_inprogress_minutes, read by ~700 flow runs a day, was NULL, and the zero-reader finding listed live QA weights (qa_final_score_threshold,qa_critic_weight) as unread. - Each
poindexterCLI command.cli/_bootstrap.py::close_cli_poolflushes the process-wide sink before it closes the command’s pool, andscripts/ci/cli_audit_sink_lint.pyalready routes every CLI pool through it. EverySiteConfiga command builds comes fromcli_site_config(pool), which passessettings_read_sink.record_readas itsread_recorder: commands build them insiderun_servicefactories and_make_site_confighelpers, and the instance is often gone before the pool closes, so its reads can’t wait on it.container_for_cliflushes its container’sSiteConfigin itsfinally. - Each auto-embed pass.
services/taps/runner.py::run_allbuilds its ownSiteConfigand flushes it before returning. The sidecar is its own process, and before this all sixtap_*keys read as never-read on prod while it read them every hour. - Every script and voice agent that loads a
SiteConfig(scripts/regen_media_scripts.py, the tools underscripts/, the parked voice agents) flushes it before its pool closes.
Keeping the list complete
scripts/ci/settings_read_flush_lint.py (in test-backend and
lint-main) finds every construction of a DB-loaded SiteConfig —
SiteConfig(pool=…), a SiteConfig(…) that is then .load()-ed or
.reload()-ed, and every call to build_container,
build_and_wire_subprocess_with_container or build_and_wire_for_subprocess
— and requires the function it sits in to flush, directly or through a
helper in the same module. A helper that only builds and returns the
SiteConfig is covered when all its callers in the module flush. Anything
else needs an ALLOWLIST entry with the reason its reads aren’t stamped
there. The entries today:
- the worker’s lifespan instance in
main.py, flushed byFlushSettingsReadTelemetryJobin another module; cli_site_config, whose reads go to the sinkclose_cli_poolflushes (the lint also rejectscli_site_configoutsidepoindexter/cli/);- the MCP server’s
_get_site_config: a long-lived adapter that caches itsSiteConfigfor the life of the process, with no teardown to flush from.
scripts/
that loads a SiteConfig and the parked voice agents, so the list stays
short and each entry is a structural exception.
A stale entry fails the lint, so an exemption can’t outlive the code it
excused.
The orphan probe
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. A key read EXCLUSIVELY via a path none
of the above covers still surfaces as an orphan candidate:
- Raw SQL that doesn’t call
record_read(e.g.findings_alert_routerreading thefindings.*policies withLIKE 'findings.%'). - A
site_config.all()snapshot filtered in code.persona_service.get_personareads everypersona.<slug>.*key this way, so they read as never read even though every narration resolves them. - A process that never flushes: the brain daemon’s own asyncpg
reads (it never builds a
SiteConfig) and the MCP server (the onlySiteConfigthe lint’sALLOWLISTexcuses without a flush elsewhere).
last_read_at is a candidate, never proof. Verify each key
from its readers in code 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.