ADR-0053 — Runtime UI config-override layer (app_config key/value store; env baseline → DB override; GET/PUT /config/app)¶
- Status: Accepted
- Date: 2026-07-03
- Sprint: v1.1 (M11 — "Convert & Configure"; R11-2 Settings redesign + A2 amendment)
- Features: F16 (Settings / config surface / i18n) · F17 (adjacent — S6
EMBEDDING_FORMAT, S5EMBEDDINGS_ENABLEDtouch the embedding data plane and MUST route through the existing adapter, never a hardcoded path) - Reference: docs/sprints/SPRINT-v1.1-SCOPE.md §2 R11-2 (migration list + exclusions) · §3 R11-2
(AC-R11-2-0..10) · §10 AMENDMENT A2.1 (Settings IA reorg) / A2.2 (first-run wizard) — the amendment
OVERRIDES the base where they differ ·
ADR-0040 (clip runtime config — GET/PUT, DB-wins-over-env, in-process cache) ·
ADR-0041 (web-search runtime config — plain-text, non-secret, same pattern) ·
ADR-0031 (
EMBEDDING_FORMATadapter seam — the seam S6 must route through) · ADR-0030 (EMBEDDINGS_ENABLEDtoggle + lexical degrade — the seam S5 must route through) · ADR-0051 (PDF_EXTRACTOR/ Marker seam — S1–S3) · ADR-0052 (SYNAPSE_AUTH_TOKEN— the auth middleware that gates the new routes by construction) - Invariants owned: I6 (S5/S6 must route through the existing embedding adapter — no backend/
provider/embedding path is ever hardcoded; the override layer feeds config to the SAME seam, never a
new one) · I7 (bounded reads — the whole
app_configtable is read ONCE at lifespan startup and re-read only on a PUT; no per-request DB scan; the allow-list caps the surface) · I8 (D2 ER + D4 OpenAPI regenerated: newapp_configtable +GET/PUT /config/appwithBearerAuth) · I1/I5 (no vault re-scan and no vault write in this layer — config is Postgres-only) - Author: solution-architect
- Implementers: backend-engineer (
app_configmodel inmodels.py+ Alembic migration +config_overrides.pycache +GET /config/app+PUT /config/app/{key}+DELETE /config/app/{key} - wire the 8 call sites through
get_effective+ pytests + ER/OpenAPI regen) · frontend-engineer (SettingsPanel section, IA reorg A2.1, first-run wizard A2.2 — writes ONLY through these endpoints) · tech-writer (D2 ER, D4 OpenAPI, USER.md "Runtime Settings") - Gate: This ADR GATES all R11-2 backend code (AC-R11-2-0). No
app_configmodel, no migration, noconfig_overrides.py, and no/config/approute may be written until this ADR is Accepted.
1. Context¶
Today every runtime knob in Synapse is an env var loaded once at process start by
pydantic-settings (backend/app/config.py, module-level settings singleton). Changing any of them
— even a user-facing one like the PDF extractor or the monthly cost-alert threshold — means editing
docker-compose.yml / .env and restarting the container. The owner (SPRINT-v1.1 §1, A2) wants a
non-technical user to tune Synapse from the UI without touching compose, while keeping the env
contract 100 % backward-compatible for existing deployments.
Three prior ADRs already solved the "UI can override an env value, persisted in Postgres, DB-wins, loaded once into an in-process cache" problem for single settings each: - ADR-0040 (clip config — a secret token → hashed, one-time reveal), - ADR-0041 (web-search URL — non-secret, returned verbatim), - ADR-0033 (MCP token — secret, hashed).
Each of those added named columns on vault_state and a bespoke cache singleton. That does not
scale to eight heterogeneous settings (and a future N), and it would bloat vault_state with one
nullable column per knob. R11-2 therefore introduces a generic key/value override table plus a
single cache module. This ADR is the technical contract the PM decision (§2 R11-2) requires before
any R11-2 code is written. It fixes the table shape, the allow-list, the merge order, the API contract,
the reset semantics, and the two hard safety properties (forward-compat for unknown keys;
backward-compat when the table is empty).
Two facts the implementer must not re-derive:
-
Auth is a middleware, not a route dependency. The task brief says "auth via the existing
verify_tokendep." The live codebase (ADR-0052 §2.2, deliberately) enforces the REST API with a singleSynapseAuthMiddlewareinstalled inmain.py; there is no per-routeDepends(verify_token)pattern (verify_token/_verify_tokeninmain.pyis a token-hash comparator for the clip/MCP surfaces, not an auth dependency). Like ADR-0052 §1, this ADR binds to the real mechanism: the newGET/PUT/DELETE /config/app*routes are ordinary REST routes on the main router and are therefore gated by construction bySynapseAuthMiddlewarethe moment they are registered — whenSYNAPSE_AUTH_TOKENis set, they requireAuthorization: Bearer <token>; when it is unset, they are open exactly like every other route (v0.9 behaviour). No extra wiring; do NOT add a per-route dependency (that would be the double-gate ADR-0052 §6 forbids). The OpenAPIBearerAuthreference is the global security requirement FastAPI already emits (§4.4). -
The 8 settings are consumed at scattered call sites via
settings.<key>. Verified sites:health.py(S5),costs.py(S4),main.py(S5, S7),ingest/orchestrator.py(S5, S7),rag/retrieval.py(S5),embeddings.py(S6 viasettings.embedding_formatinEmbeddingClient.__init__),ingest/extract.py(S1/S2/S3), the wikilink post-pass (S8). The override is only real if these call sites read the effective value, not the raw env value. §2.5 specifies exactly how, and the I6-critical S5/S6 routing.
2. Decision¶
2.1 A generic app_config key/value table (NOT more vault_state columns)¶
One table, one row per overridden setting. Absent row ⇒ the env baseline governs.
CREATE TABLE app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
key— the config key, stored as the lower-snake attribute name used on thesettingsobject (e.g.pdf_extractor,embedding_format,cost_alert_threshold_usd). This is the same token the API path uses (PUT /config/app/pdf_extractor) and the same token the frontend sends. The env var name (PDF_EXTRACTOR) is the UPPER form of the same identifier; we standardise on the attribute form everywhere in the DB and the API so there is exactly one canonical key spelling.value— always TEXT,NOT NULL. Typed settings (int/float/bool) are stored as their string form ("120","5.0","true") and coerced at read time by the typed accessors (§2.5). ANOT NULLvalue column is the reason reset-to-default is a row DELETE, not avalue: nullwrite (§3): there is no in-band "null means default" sentinel to store, which keeps the table honest ("a row exists ⇔ an override is active") and avoids a nullable-value ambiguity.updated_at— audit / last-write,DEFAULT now(); refreshed on every upsert.
Why a generic table, not per-key columns on vault_state — eight (and growing) heterogeneous
knobs would add eight nullable columns and eight bespoke cache fields, each needing a migration. A
key/value table adds settings with zero schema change after this one migration, and the allow-list
(§2.2) — not the table shape — is the safety boundary. This is the single deliberate departure from the
ADR-0040/0041 per-column pattern, justified by cardinality.
Alembic migration is required (next id in sequence). ER drift check (make er) MUST show the new
table; docs/er/schema.mmd committed zero-drift (AC-R11-2-1, EC-M11-3, I8).
2.2 The allow-list is the security boundary — exactly 8 keys, a named constant¶
A module-level frozen constant in config_overrides.py is the single source of truth for which
keys may ever be overridden:
# config_overrides.py — the ONLY keys the UI may override (SPRINT-v1.1 §2 R11-2)
ALLOWED_CONFIG_KEYS: frozenset[str] = frozenset({
"pdf_extractor", # S1 (ADR-0051)
"marker_service_url", # S2 (ADR-0051)
"marker_timeout_seconds", # S3 (ADR-0051)
"cost_alert_threshold_usd", # S4 (R9-1)
"embeddings_enabled", # S5 (ADR-0030) — routes through the embedding data-plane gate
"embedding_format", # S6 (ADR-0031) — routes through the embedding adapter seam (I6)
"overview_language", # S7 (F3)
"wikilink_enrich_enabled", # S8 (ADR-0036)
})
- A PUT/DELETE for any key not in this set → 400 with
{"error": "invalid_key", "allowed": [...]}whereallowedis the sorted list (AC-R11-2-3). No infra/secret key can ever be written through this surface, even if a row somehow existed for it (§2.6 — such rows are ignored on load). - The list is exactly the MIGRATED set of §2 R11-2. The EXCLUDED set (§2.4 below) is never in this constant.
De-scope note (SPRINT-v1.1 §8): if S7 (
overview_language) and S8 (wikilink_enrich_enabled) are cut from the first UI delivery, they MUST also be removed fromALLOWED_CONFIG_KEYSin the same change (the allow-list and the UI must not diverge). The table shape and merge logic are unaffected.
2.3 Value validation per key (fail-closed, never persist garbage)¶
PUT /config/app/{key} validates the value against a per-key rule before upsert; on failure →
422 (Pydantic-style) and no write. Minimum rules (backend-engineer may tighten):
| Key | Type | Rule | On violation |
|---|---|---|---|
pdf_extractor |
enum | ∈ {"pypdf","marker"} |
422 |
marker_service_url |
url | starts with http:// or https:// (mirrors ADR-0041 §2.4) |
422 |
marker_timeout_seconds |
float | > 0, ≤ 3600 |
422 |
cost_alert_threshold_usd |
float | ≥ 0 (0 disables the alert — matches config.py) |
422 |
embeddings_enabled |
bool | ∈ {"true","false"} (case-insensitive) |
422 |
embedding_format |
enum | ∈ {"ollama","openai"} (matches ADR-0031 allowed values) |
422 |
overview_language |
str | sentinel | free text ISO code, OR the "(auto)" sentinel → stored as a DELETE (see below) |
— |
wikilink_enrich_enabled |
bool | ∈ {"true","false"} (case-insensitive) |
422 |
S7 (auto) handling: the env baseline for overview_language is None ("auto-detect"). Because
app_config.value is NOT NULL, there is no way to store "auto" as a value; the UI's "(auto)" choice
therefore maps to reset-to-default = DELETE the row (§3), which restores the None/auto baseline.
Any non-empty ISO string is stored verbatim.
2.4 Excluded infra/secret keys — env-only, never in the UI (from §2 R11-2)¶
These keys are NOT in ALLOWED_CONFIG_KEYS and can never be set via /config/app. Reproduced from
SPRINT-v1.1 §2 R11-2 so the contract is self-contained:
| Key | Class | Reason for exclusion |
|---|---|---|
DATABASE_URL |
infra secret | changing it at runtime orphans all data |
QDRANT_URL |
infra | live change corrupts the collection pointer |
EMBEDDING_URL |
infra | tied to the running bge-m3 instance |
EMBEDDING_DIM |
infra | changing requires a full re-embed of the vault |
VAULT_PATH |
infra | changing while the watcher runs breaks the incremental index (I1) |
SYNAPSE_AUTH_TOKEN |
security | env-only by design (ADR-0052 §2.1); has its own Settings › Security surface |
CORS_ALLOW_ORIGINS |
infra | network-topology decision |
SEARXNG_URL |
infra | tied to the running SearXNG instance (its own runtime config exists — ADR-0041) |
CLIP_TOKEN |
security secret | env-only (ADR-0038 §2.1); clip has its own PUT /clip/config for the DB token |
MCP_AUTH_TOKEN |
security secret | handled via PUT /mcp/auth (ADR-0033) |
MCP_TRUSTED_PROXIES |
infra | network topology |
MAX_UPLOAD_BYTES |
infra | storage constraint |
Rationale, one line: every excluded key is either (a) a secret whose blast radius or replay semantics require env-only or a dedicated hashed/one-time surface (auth token, clip token, MCP token), or (b) an infrastructure pointer whose runtime mutation would corrupt data, the vector store, the incremental index (I1), or the network posture. None is a per-day-use "behaviour tuning" knob — the migration criterion is "user-facing, runtime-tunable, safe to change live," which none of these meet.
2.5 The override layer — config_overrides.py: load once, cache, get_effective(key, env_default)¶
New module backend/app/config_overrides.py. Contract:
ALLOWED_CONFIG_KEYS: frozenset[str] = frozenset({...}) # §2.2
async def load_overrides(session) -> None:
"""Lifespan startup: read ALL app_config rows ONCE, cache in memory.
Rows whose key ∉ ALLOWED_CONFIG_KEYS are IGNORED (forward/back compat — §2.6).
Single bounded SELECT; no vault scan, no per-request DB read (I7)."""
def get_effective(key: str, env_default: str) -> str:
"""Return the cached override string for `key` if present, else `env_default`.
Pure in-memory O(1); never touches the DB."""
def source_of(key: str) -> str: # "override" if a cached row exists for key, else "env"
def get_override(key: str) -> str | None: # cached raw value or None (per §2 R11-2 AC-R11-2-5 shape)
async def set_override(session, key, value) -> None: # allow-list + validate + upsert + refresh cache
async def clear_override(session, key) -> None: # allow-list + DELETE row + refresh cache
Merge order (single, unambiguous): env baseline FIRST, DB override ON TOP, for the 8 allowed keys
only. settings.<key> is the deploy-time default; get_effective(key, str(settings.<key>)) is the
effective value. There is no third layer. Nothing else changes.
Typed reads. Because settings.<key> is variously str | bool | float | None, the call sites use
thin typed helpers colocated in config_overrides.py (so coercion lives in one place, mypy-strict,
no Any):
- effective_str(key, default: str | None) -> str | None
- effective_bool(key, default: bool) -> bool ("true"/"1"/"yes" → True; else False)
- effective_float(key, default: float) -> float
The 8 call sites migrate to the effective accessor (this is the load-bearing wiring — an override that no call site reads is a no-op):
| Setting | Current read | New read |
|---|---|---|
S1 pdf_extractor |
settings.pdf_extractor in ingest/extract.py |
effective_str("pdf_extractor", settings.pdf_extractor) |
S2 marker_service_url |
settings.marker_service_url |
effective_str("marker_service_url", settings.marker_service_url) |
S3 marker_timeout_seconds |
settings.marker_timeout_seconds |
effective_float("marker_timeout_seconds", settings.marker_timeout_seconds) |
S4 cost_alert_threshold_usd |
settings.cost_alert_threshold_usd in costs.py |
effective_float("cost_alert_threshold_usd", settings.cost_alert_threshold_usd) |
S5 embeddings_enabled |
settings.embeddings_enabled in health.py, main.py, orchestrator.py, retrieval.py |
effective_bool("embeddings_enabled", settings.embeddings_enabled) |
S6 embedding_format |
settings.embedding_format in embeddings.py EmbeddingClient.__init__ |
effective_str("embedding_format", settings.embedding_format) fed into the SAME adapter constructor |
S7 overview_language |
settings.overview_language in main.py, orchestrator.py |
effective_str("overview_language", settings.overview_language) (None ⇒ auto) |
S8 wikilink_enrich_enabled |
settings.wikilink_enrich_enabled |
effective_bool("wikilink_enrich_enabled", settings.wikilink_enrich_enabled) |
I6 — the S5/S6 routing rule (non-negotiable). S6 (embedding_format) MUST be applied by passing the
effective value into the existing EmbeddingClient adapter seam (ADR-0031: EmbeddingClient(embedding_format=...)
or by having the client read get_effective at construction) — NOT by adding a new branch that hardcodes
an embedding request/response shape anywhere. The adapter (ollama vs openai body/parse) stays the ONE
place that knows the shapes. Likewise S5 (embeddings_enabled) MUST feed the existing ADR-0030
data-plane gate (ingest-skip / retrieval lexical-degrade / startup validation) — no new bypass path.
The override layer only changes where the value comes from, never how it is applied. Reviewer will
reject any S5/S6 wiring that introduces a second code path (I6).
No live re-embed / no re-scan on change (I1). Toggling S5 or changing S6 at runtime does exactly
what ADR-0030/0031 already specify for the env value: it changes behaviour for subsequent ingests/
retrievals. It MUST NOT trigger a bulk re-embed or a vault re-scan. Startup embedding validation
(_validate_embedding_and_collection) reads the effective S5 at lifespan start (after
load_overrides), so an override that turns embeddings off is honoured on the next boot without env
changes; an in-session toggle takes effect on the next ingest/query (documented behaviour, not a bug).
2.6 Startup safety — forward-compat (unknown keys ignored) and backward-compat (empty ⇒ pure env)¶
- Forward/backward-compat on load:
load_overridesfilters rows byALLOWED_CONFIG_KEYS. A row whose key is unknown/removed (an old key, a typo, a de-scoped S7/S8) is silently ignored — never applied, never crashes startup. This lets a key be removed from the allow-list in a later version without a data migration, and lets a newer DB be read by an older binary. - Backward-compat (EC-M11-13): an empty
app_configtable (no rows) meansget_effectivereturns the env default for every key ⇒ behaviour is byte-for-byte identical to v1.0.0. A deployment that never touches the UI is unaffected; the v1.0 E2E suite passes unchanged against a v1.1 build with an empty table. This is the load-bearing compatibility property and MUST have an explicit test. - Deployment without the migration applied:
load_overridestolerates a missingapp_configtable (catch the "relation does not exist" path, log once, treat as empty) exactly as ADR-0041 §6 tolerates missing columns — env governs, zero behaviour change. (Belt-and-braces; the migration is required in the normal path.)
3. API contract¶
All three routes are ordinary REST routes on the main router, gated by SynapseAuthMiddleware
(ADR-0052) when SYNAPSE_AUTH_TOKEN is set, open otherwise. They MUST carry the global BearerAuth
security requirement in OpenAPI (§4.4). No per-route auth dependency (would double-gate — ADR-0052 §6).
3.1 GET /config/app — list all 8 settings with effective value + source¶
GET /config/app
→ 200 {
"settings": [
{ "key": "pdf_extractor", "value": "pypdf", "source": "env" },
{ "key": "marker_service_url", "value": "http://host.docker.internal:8555", "source": "env" },
{ "key": "marker_timeout_seconds", "value": "120", "source": "env" },
{ "key": "cost_alert_threshold_usd", "value": "5.0", "source": "env" },
{ "key": "embeddings_enabled", "value": "true", "source": "env" },
{ "key": "embedding_format", "value": "ollama", "source": "env" },
{ "key": "overview_language", "value": "", "source": "env" },
{ "key": "wikilink_enrich_enabled", "value": "true", "source": "env" },
...
]
}
- Returns exactly the keys in
ALLOWED_CONFIG_KEYS(order = sorted or the S1..S8 order; stable). valueis always the effective value as a string (override wins; env baseline otherwise). For S7 with no override the effective value isNone→ serialised as""(empty string sentinel the UI renders as "(auto)").sourceis"override"iff a cachedapp_configrow exists for the key, else"env".- All values derived from the in-process cache — no DB round-trip per GET (I7).
- Never returns any excluded/secret key (they are not in the allow-list, hence not iterated).
3.2 PUT /config/app/{key} — upsert one override¶
PUT /config/app/{key}
body: { "value": "<string>" } # typed values sent as their string form
→ 204 No Content # on success (upsert + cache refresh)
→ 400 { "error": "invalid_key", "allowed": ["cost_alert_threshold_usd", ...] } # key ∉ allow-list
→ 422 { ...validation detail... } # value fails the per-key rule (§2.3)
→ 401 # if SYNAPSE_AUTH_TOKEN set and bearer missing/wrong (middleware)
- Upserts
app_config(key, value, updated_at=now())by primary key, then refreshes the in-process cache so a subsequentGETimmediately reflectssource: "override"and the new value (AC-R11-2-2b). - A pytest asserts: valid key+value → 204 and
sourceflips to"override"; unknown key → 400 with the sortedallowedlist; a value violating §2.3 → 422 with no row written.
3.3 Reset-to-default — DECISION: DELETE /config/app/{key} (chosen over PUT value:null)¶
DELETE /config/app/{key}
→ 204 No Content # override row removed; setting reverts to env default
→ 400 { "error": "invalid_key", "allowed": [...] } # key ∉ allow-list
→ 401 # middleware, as above
Why DELETE, not PUT {value: null} (justified):
app_config.valueisNOT NULLby design (§2.1). APUT {value: null}would either require a nullable value column (re-introducing the "row exists but means default" ambiguity we deliberately removed) or a magic in-band null that the layer must special-case on every read. DELETE keeps the table invariant crisp: a row exists ⇔ an override is active.sourcederivation, cache logic, and the "empty table ⇒ pure env" property (§2.6) all fall out of that invariant for free.- It is the correct REST verb — "remove the override resource" is a deletion, not an update to a null state. It reads unambiguously in the OpenAPI schema and in client code.
- It matches the layer's mental model — env is the immutable baseline the app cannot edit; the override is a separate resource laid on top. Creating it is PUT; removing it is DELETE. There is no third "null" state to represent.
The frontend "Reset to default" action calls DELETE /config/app/{key}; the S7 "(auto)" choice maps to
the same DELETE (§2.3). SPRINT-v1.1 AC-R11-2-4 explicitly permits DELETE as the implementation — this
ADR binds it as the single reset mechanism. (PUT {value:null} is NOT implemented; if a client
sends value:null the request is rejected 422 by body validation — value is a required non-null
string — steering all resets to DELETE.)
4. Persistence, cache, and OpenAPI wiring (mirrors the ADR-0040/0041 proven pattern)¶
4.1 In-process cache, loaded at lifespan startup¶
- A single module-level cache in
config_overrides.py(a dictkey → value, guarded by anasyncio.Lockfor the PUT/DELETE refresh, mirroring_ClipConfigCache/_WebSearchConfigCache). load_overrides(session)is called once during lifespan startup, inmain.py, in the same block that calls_load_clip_config_cache()/_load_web_search_config_cache()and before_validate_embedding_and_collection(so the effective S5 governs startup validation — §2.5).- Every
GET /config/appand every call site read is O(1) in-memory. The DB is touched only at startup and on a PUT/DELETE (I7 — no unbounded / per-request reads).
4.2 Endpoint shape mirrors clip/web-search config¶
GET/PUT/DELETE /config/app* follow the exact request→validate→persist→refresh-cache→respond shape
of PUT /clip/config (main.py) — Pydantic request/response models, session scope for the write,
cache refresh outside the session, structured log line on write (key + source; never log a
value — some are innocuous but the pattern must be uniform and a future secret-adjacent key must not
leak). No new dependency; reuses SQLAlchemy 2 + the existing session helper.
4.3 ER (D2, I8)¶
app_config is added to models.py as an ORM model (__tablename__ = "app_config", key TEXT PK,
value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL server_default now()), so make er emits it
into docs/er/schema.mmd with zero drift (EC-M11-3). The Alembic migration is the next id in sequence.
4.4 OpenAPI (D4, I8)¶
docs/api/openapi.json regenerated so GET /config/app, PUT /config/app/{key}, and
DELETE /config/app/{key} are declared with correct request/response schemas AND the global
BearerAuth security reference (they are NOT in the ADR-0052 exempt set, so they inherit the global
security: [{"BearerAuth": []}] — the reviewer verifies each of the three appears with BearerAuth,
AC-R11-2-8 / EC-M11-4).
5. Settings IA (A2.1) and the wizard (A2.2) — persistence is this ADR; grouping is the frontend's¶
The Settings information-architecture reorg (A2.1) and the first-run wizard (A2.2) are frontend concerns; their persistence contract is this ADR and nothing else.
- Endorsed (PM-and-owner-locked): the ~14 current sections collapse to a ~5-group target (Getting started / AI & Models / Sources & PDF / Output & Appearance / Advanced — A2.1). The grouping, labels, and inline help are the frontend-engineer's to finalise; this ADR does not fix the exact split. This ADR endorses the target group count (~5) and the plain-language rule.
- "No env-var-name as primary label" rule (endorsed): no visible primary field label may equal an
env-var name (e.g. the S1 label is "PDF extractor / Estrattore PDF", never
PDF_EXTRACTOR). Enum values that are already readable (pypdf,marker,ollama,openai) may be shown as-is. An optional "Advanced" affordance MAY reveal the underlying key for power users. (AC-R11-2-11/12.) - Single source of truth for persistence: grouping is presentation only. The only way the
UI (SettingsPanel AND the first-run wizard) mutates any of the 8 settings is
PUT /config/app/{key}/DELETE /config/app/{key}; provider/model choices in the wizard go through the existing provider-config endpoints (ADR-0008/0021). No parallel persistence path — the wizard writes nothing directly to the DB, nothing to env, nothing to a new endpoint. A Vitest spies onfetchand asserts the wizard writes only through these sanctioned endpoints (AC-R11-2-13). - Wizard "unconfigured" detection reads
GET /config/app+ the provider-config GET; it is bounded, skippable, and re-openable from "Getting started" (A2.2). No backend change beyond this ADR's endpoints is required for the wizard.
6. Do-NOT list¶
- DO NOT add per-key columns to
vault_statefor these 8 settings — use the genericapp_configtable (§2.1). (The ADR-0040/0041 per-column pattern does not scale to N keys.) - DO NOT allow any key outside
ALLOWED_CONFIG_KEYS— PUT/DELETE for a non-allowed key → 400 with the sortedallowedlist (§2.2). No infra/secret key is ever writable here (§2.4). - DO NOT hardcode an embedding request/response shape or a provider path when applying S5/S6 —
route the effective value through the existing ADR-0030 data-plane gate (S5) and the ADR-0031
EmbeddingClientadapter seam (S6). A second code path is an I6 violation and will be rejected (§2.5). - DO NOT trigger a re-embed or a vault re-scan when S5/S6 change — behaviour applies to subsequent ingests/queries only (I1, §2.5).
- DO NOT read
app_configper request — load once at lifespan, cache in memory, refresh only on PUT/DELETE (I7). Nowhileloop, no unbounded scan (single bounded SELECT). - DO NOT implement reset as
PUT {value: null}— reset isDELETE /config/app/{key}(§3.3);app_config.valuestaysNOT NULL;valueis a required field in the PUT body. - DO NOT add a per-route auth dependency to the new routes — they are gated by
SynapseAuthMiddlewareby construction (ADR-0052); a per-routeDependsdouble-gates (ADR-0052 §6). - DO NOT log an override value — log
key+sourceonly (uniform with the clip/web-search pattern; forward-safe if a future key is secret-adjacent). - DO NOT let an unknown/removed key in
app_configcrash startup or be applied — filter by the allow-list on load; ignore the rest (forward/back compat, §2.6). - DO NOT change the env contract — an empty
app_configtable is byte-for-byte v1.0.0 behaviour (EC-M11-13); existing env-only deployments are unaffected (§2.6). - DO NOT let the frontend wizard or SettingsPanel write config through any path other than
PUT/DELETE /config/app/{key}and the existing provider-config endpoints (§5) — no parallel persistence. - DO NOT skip the ER/OpenAPI regen —
docs/er/schema.mmd(newapp_configtable) anddocs/api/openapi.json(3 new routes +BearerAuth) MUST be drift-clean (I8, EC-M11-3/4).
7. Acceptance checks (DoD — maps to AC-R11-2-0..14)¶
- ADR accepted before code — this file is Accepted and in the ADR index before any R11-2 backend code (AC-R11-2-0, EC-M11-9).
- Table + migration — Alembic migration creates
app_config (key TEXT PK, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()); ER zero-drift; a pytest confirms the table exists and supports upsert-by-key (AC-R11-2-1, EC-M11-3). - GET contract —
GET /config/appreturns{"settings":[{key,value,source}]}for the 8 keys; with no overrides allsource == "env"; after a PUT the affected key flips to"override"andvaluechanges; effective value wins (AC-R11-2-2). - PUT contract — valid key+value → 204 + upsert; unknown key → 400
{"error":"invalid_key","allowed":[...]}; a §2.3 rule violation → 422 no write; 401 under auth-on with a bad/absent bearer (AC-R11-2-3). - Reset = DELETE —
DELETE /config/app/{key}removes the row and reverts to the env default; a pytest asserts the revert (AC-R11-2-4, §3.3). - Layer logic —
load_overridescalled once at lifespan;get_effective(key, env_default)returns override-else-default; typed accessors coerce int/float/bool; a pytest asserts the merge with mocked rows (AC-R11-2-5, §2.5). - I6 routing — a test/review confirms S6 flows through
EmbeddingClient(no new shape branch) and S5 through the ADR-0030 gate (no new bypass) (§2.5, §6.3). - Backward compat (EC-M11-13) — empty
app_config⇒ v1.0 behaviour; the v1.0 E2E suite passes against a v1.1 build with an empty table (§2.6). - Forward compat — a row with a key ∉ allow-list is ignored on load; startup does not crash (§2.6).
- OpenAPI (I8) — the 3 routes present with correct schemas +
BearerAuth;make openapidrift-clean (AC-R11-2-8, EC-M11-4). - Frontend persistence boundary — SettingsPanel + wizard write only via
/config/app+ provider-config endpoints (fetch-spy test) (AC-R11-2-13, §5). - mypy strict / lint —
config_overrides.pyand the new routes passruff+black --check+mypy(strict), noAny(AC-R11-2-9).
8. Consequences¶
Positive — the owner tunes 8 real user-facing knobs from the UI, persisted across restarts, with zero change to the env contract (empty table = v1.0.0). A single generic table + one cache module scales to future settings with no schema churn, and the allow-list is the security boundary — infra and secret keys are structurally unreachable. The pattern is a direct, proven extension of ADR-0040/0041 (load-once cache, DB-wins-over-env, GET/PUT), so review surface is small. The I6 rule is preserved by feeding effective values into the existing embedding/provider seams, never a new path. The wizard/IA reorg get a single, unambiguous persistence contract, preventing a parallel write path.
Trade-offs (explicit) —
- The 8 call sites must be migrated to get_effective — an override that no call site reads is a
silent no-op. This is the main implementation risk; §2.5 enumerates every site and the test in §7.6
guards the merge logic. Reviewer verifies each call site individually.
- S5/S6 in-session changes apply on the next ingest/query, not retroactively — no live re-embed
(I1). Documented as intended behaviour (USER.md "Runtime Settings"), not a bug. Startup validation
reads the effective S5, so a persisted toggle is fully honoured on the next boot.
- value TEXT NOT NULL + typed coercion at read — all values stored as strings; the typed
accessors are the single coercion point. A malformed stored value can only exist if it bypassed §2.3
validation (it cannot via the API); the accessors fail closed to the default on a coercion error.
- Reset is DELETE, not a null write — one extra verb, bought for a crisp "row exists ⇔ override
active" invariant (§3.3). Clients that expected PUT {value:null} get a 422; the UI uses DELETE.
- Auth is the ADR-0052 middleware, not a route dep — the brief's "verify_token dep" does not exist
as an auth mechanism; binding to the real middleware keeps gating default-secure and avoids the
double-gate (ADR-0052 §6).
Invariant check — I6: S5/S6 route through the existing embedding gate/adapter; no provider,
model, or embedding shape is hardcoded; the allow-list is a named constant. I7: whole-table read
once at startup + on write; O(1) per-request reads from cache; no unbounded loop. I8: ER +
OpenAPI (with BearerAuth) regenerated. I1/I5: no vault re-scan, no vault write — config lives in
Postgres only; S5/S6 changes never re-embed or re-scan. I2/I3/I4/I9: untouched.
```