Settings Service
File:src/cofounder_agent/poindexter/services/settings_service.py
Tested by: src/cofounder_agent/tests/unit/services/test_settings_service.py
Last reviewed: 2026-09-28
What it does
SettingsService is the write path for the app_settings table:
upsert, delete, and category-scoped reads. It also has a TTL-cached
read path (get, get_all) that the /api/settings route handler
and the OpenClaw setting-management UI use directly.
Every other service in the codebase reads settings through SiteConfig
(docs/architecture/services/site_config.md), which has its own
in-memory cache populated at startup. SettingsService is the layer
that mutates the underlying table; SiteConfig is the layer that
exposes it to consumers. When a setting is written here, downstream
readers won’t see it until either SiteConfig.reload() is called or
the app restarts.
It’s instantiated once in main.py lifespan
(SettingsService(db_pool)) and registered in services.container
under the key "settings". It’s distinct from SiteConfig —
SettingsService is async-everywhere, takes a raw asyncpg pool, and
hits the DB on every write.
Public API
SettingsService(pool)— constructor. Stores the asyncpg pool and initializes an empty cache with a 60-second TTL.await svc.get(key, default=None) -> str | None— TTL-cached read. Falls back to the uppercase env var when the DB row is empty, matchingSiteConfig.get()semantics for migration compatibility. Records the key into the process-wideservices.settings_read_sinksoapp_settings.last_read_atgets stamped (poindexter#756) — byFlushSettingsReadTelemetryJobin the worker, or when the process that read it finishes: a Prefect content-flow run (multi_model_qa’s reads happen there), apoindexterCLI command (close_cli_pool) or an auto-embed pass.get_all/get_by_categorydeliberately do NOT record — those bulk/admin reads would stamp the whole table and blind the zero-reader probe.await svc.get_by_category(category) -> dict[str, str]—{key: value}for every cached row whosecategorycolumn matches.await svc.set(key, value, category=None, description=None, is_secret=None)— upsert. Optional fields are only updated on conflict if explicitly passed (None means “leave the existing column alone”). A NEW row takes its category fromresolve_category(key)when none is given (the same answer the boot seeder gives) andis_secretFALSE. Invalidates the local cache so the nextget_*call re-fetches.await svc.delete(key)— DELETE by key. Logs the result string and invalidates the cache.await svc.get_all(include_secrets=False) -> list[dict]— full dump of[{key, value, category, description, is_secret, updated_at}], sorted by key. Secret values are replaced with"********"unlessinclude_secrets=True.await svc.refresh_cache()— force a re-read from DB. Called automatically by_ensure_cache()when the cache is empty or stale.
Key behaviors / invariants
- Cache is per-instance, not process-global.
SettingsServicehas its own dict;SiteConfighas its own dict. Writing through this service does NOT updateSiteConfig’s cache. Callers that depend on the new value must either go throughSettingsServicefor the read too, or callapp.state.site_config.reload(pool)afterward. - TTL is 60 seconds, hardcoded. Set in
__init__asself._cache_ttl = 60. Not currently DB-configurable. - Cache invalidation is a 0-timestamp reset, not a clear.
set()anddelete()both setself._last_refresh = 0, so the next read triggersrefresh_cache()rather than waiting out the TTL. - Secret masking is opt-out, not opt-in.
get_all()masks any row withis_secret=trueunless the caller explicitly passesinclude_secrets=True. The single-keyget()returns the real value with no masking — secret-aware UI surfaces should useget_all()or checkis_secretseparately. get()env-var fallback is uppercased.await svc.get("foo_bar")falls through toos.getenv("FOO_BAR"). This matchesSiteConfigbut means settingfoo_barin the DB to""(empty string) will silently fall through to env — empty isn’t the same as “no row.”- Write fields with COALESCE — on the bound parameter, never on
EXCLUDED. The upsert’s DO UPDATE arms useCOALESCE($N::<type>, app_settings.<col>)for category, description and is_secret, so passingNonepreserves the existing value rather than overwriting it.EXCLUDEDis the row proposed for insertion, and the new-row fallbacks in VALUES (COALESCE($3, ...),COALESCE($5, FALSE)) make it non-NULL, soCOALESCE(EXCLUDED.<col>, app_settings.<col>)can never reach the existing value. That is what this method used to do: until 2026-09-28set(key, value)re-filed an existing row undergeneral(the console chat’sset_settingtool is exactly that call) and clearedis_secreton a secret row, contradicting this very paragraph. Any new upsert of the “keep it unless supplied” kind has to read the parameter, and needs a real-Postgres test, since a mock cannot run the COALESCE (tests/integration_db/test_settings_writers_keep_existing.py).AdminDatabase.set_settinghad the same trap ondescription(fixed alongside); itscategory=Nonedeliberately resolves from the key instead of keeping the row’s own.
Configuration
SettingsService reads no app_settings keys itself. Its only
configuration is the constructor’s pool argument and the
hardcoded _cache_ttl = 60 constant.
The schema it operates against (app_settings table) has columns:
key TEXT PRIMARY KEY, value TEXT, category TEXT DEFAULT 'general', description TEXT, is_secret BOOLEAN DEFAULT FALSE, updated_at TIMESTAMP.
Dependencies
- Reads from:
app_settingstable (full table scan inrefresh_cache).
- Writes to:
app_settingstable (single-row upserts and deletes).
- External APIs: none.
- Callers:
main.pylifespan — instantiates and registers it.services.container— stores it under key"settings"for DI./api/settingsand/api/settings/{key}route handlers — use it for both reads (with masking) and writes.services/chat_tools.py::_set_setting— the console chat’s setting tool:SettingsService(pool).set(key, value)on an existing, non-secret row (it refuses new keys and secret rows itself).services/persona_service.py::upsert_persona— writespersona.<slug>.*rows with an explicit category and description.- OpenClaw setting-management UI — same routes.
Failure modes
- DB connection failure during
refresh_cache()— caught, logged as error, leavesself._cachein its previous state. Subsequentget()calls serve stale data. This is intentional — better to serve a 60-second-old value than to error out the request — but means a long DB outage will eventually serve very stale data with no alarm raised here. Watch the application-level DB-health alerts. - Write failure —
set()anddelete()propagateasyncpgexceptions to the caller (no swallow). The cache is NOT invalidated on failure, so a failed write doesn’t leave the cache in a bogus state. - Race between writers — two concurrent
set()calls are serialized by Postgres’INSERT ... ON CONFLICT. The cache reset flag is set unconditionally, so the next read always sees the winner. - Cache-vs-
SiteConfigdivergence — if you write a value throughSettingsServiceand immediately read throughSiteConfig, you may see the old value until either (a) the calling layer triggersSiteConfig.reload(pool), or (b) you restart the app. The/api/settingsPUT handler should callreload()after a successful write — verify in the route code if you suspect this is out of sync.
Common ops
- Set a value via Python (one-off script):
- Set a value via CLI:
poindexter settings set <key> <value>— a direct DB upsert with its own statement (it does not go through this service or the API, so it can run while the worker is down). Its--category/--descriptionsemantics are documented indocs/operations/cli-reference.md. - List everything (with secrets revealed):
Don’t log the result — secrets included means secrets in logs.
- Force a cache refresh:
await services["settings"].refresh_cache(). Useful from a debug endpoint or one-off shell. - Find which categories exist:
SELECT DISTINCT category FROM app_settings ORDER BY 1; - Check the cache TTL: it’s hardcoded to 60s — if you need it
shorter for development, change
self._cache_ttlin__init__. (Long-term, this should be anapp_settingskey likesettings_service_cache_ttl_secondsper the project’s “config in DB, not code” stance.)
See also
docs/architecture/services/site_config.md— the read path used by every service. Read its “How callers should plumb the instance” section before adding a new consumer.services.settings_defaults.seed_all_defaults— populates new keys with sensible defaults at startup so a fresh DB doesn’t need manual seeding.feedback_db_first_config(operator design note) — the project’s “everything tunable in DB” policy this service enforces.feedback_no_silent_defaults(operator design note) — note that the env-var fallback inget()is the ONE silent default the project tolerates, kept only for migration compatibility.