Settings Service
File:src/cofounder_agent/services/settings_service.py
Tested by: src/cofounder_agent/tests/unit/services/test_settings_service.py
Last reviewed: 2026-04-30
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_sinksoFlushSettingsReadTelemetryJobstampsapp_settings.last_read_at(poindexter#756).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”). 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. The upsert SQL uses
COALESCE($N, app_settings.<col>)for category, description, is_secret — so passingNonepreserves the existing value rather than overwriting with NULL.
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.- 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>— wraps this service through the API. - 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.