phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+8 -6
View File
@@ -3,12 +3,14 @@ layer, the phase-59 docs-push flag (the "Save as doc" gating), the
phase-122 image flag (UI affordance gating), and the phase-62 UI
customization strings (composer placeholder, footer line).
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
wins when set, env is the fallback), resolved by the SAME
:func:`app.core.theming.effective_settings` resolver the
``/api/ui-settings`` API uses, so the brand layer and the tab can never
disagree. The route opens a short-lived session (the sync-endpoint
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values,
resolved by the SAME :func:`app.core.theming.effective_settings` resolver
the ``/api/ui-settings`` API uses, so the brand layer and the tab can
never disagree. Phase 190 (task 02, A1) fixed WHO owns them: the admin
Settings panel's ``app_settings`` row (phase 168) first, then the
legacy ``ui_settings`` (Theme-tab) row, then the env value — so a name
set in the panel is what this endpoint serves, whatever an old Theme-tab
save baked into the other row. The route opens a short-lived session (the sync-endpoint
house pattern — the route is sync, matching the middleware world).
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
+39 -38
View File
@@ -1,10 +1,14 @@
"""UI settings admin API (phase 91, task 01).
"""UI settings admin API (phase 91, task 01; colors-only since phase 190).
The persistence surface of the admin Theme tab (the tab itself lands in
tasks 04/05): the single ``ui_settings`` row (id 1) that stores what the
admin sets — the app name, input placeholder, footer text, and the 17
admin sets — since phase 190 (A1, owner-confirmed 2026-10-18) the 17
palette colors (the 9 identity colors + the 8 semantic state colors,
phase 93 — B3 revised). The whole router sits behind
phase 93 — B3 revised) and NOTHING else. The three brand strings (app
name, input placeholder, footer text) belong to the admin Settings
panel (``app_settings``, phase 168): this row keeps its string columns
as the documented FALLBACK for values it baked in before that phase, but
the API never writes them (see the PUT bullet). The whole router sits behind
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
403 on every route (only the admin themes the deployment).
@@ -14,16 +18,25 @@ Routes (under ``/api`` via the ``main`` registration):
* ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's
DB-over-env / DB-over-built-in merge, B1): a missing row reports the
env strings + the built-in palette, so a fresh tab shows the live
theme. Creates nothing.
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
string is trimmed (empty → NULL, >300 → 422 naming the field), each
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
naming the field), and — the owner-locked normalization — a color
theme. Creates nothing. The three strings stay in the response
READ-ONLY (old-client compat, and the resolver's order makes them the
EFFECTIVE ones — the panel's value when the panel owns the field).
* ``PUT /api/ui-settings`` — a FULL replacement of the row's 17 COLORS:
each color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else
422 naming the field), and — the owner-locked normalization — a color
equal to its built-in is stored as NULL, so "save the defaults"
leaves the row empty and the served HTML stays byte-identical (the
no-op injection contract, task 02). Upserts the id-1 row (SELECT →
update-or-insert); a concurrent PUT is single-admin — last writer
wins. Returns the new effective values.
no-op injection contract, task 02). The 3 string fields of the body
are ACCEPTED AND IGNORED (phase 190 A1 — the request model keeps them
so an old cached client still saves its palette; the handler drops
them BEFORE validating or persisting, so a Theme-tab save can never
re-bake a brand string and shadow the Settings panel again — the
``TODO.md`` L3 incident). The stored string columns are left exactly
as they were, never cleared: with no panel row a legacy baked value
still applies (the A1 fallback), and "reset" for a string is the
panel's ``DELETE /api/admin/settings/{key}``. Upserts the id-1 row
(SELECT → update-or-insert); a concurrent PUT is single-admin — last
writer wins. Returns the new effective values.
"""
from __future__ import annotations
@@ -49,28 +62,6 @@ router = APIRouter(
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
#: lowercased on store so the stored/tagged hex is canonical).
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
#: The strings' column length (mirrors ``ui_settings`` VARCHAR(300)).
_MAX_STRING_LEN = 300
def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
"""Trim the 3 display strings: empty after the trim → ``None``
(the clear operation), >300 chars after the trim → 422 naming the
field (the house fixed-detail style — the detail never varies by
value beyond naming the field)."""
values: dict[str, str | None] = {}
for field in theming.STRING_FIELDS:
raw = getattr(payload, field)
if raw is None:
values[field] = None
continue
value = raw.strip()
if len(value) > _MAX_STRING_LEN:
raise HTTPException(
status_code=422, detail=f"{field} is too long (max 300)"
)
values[field] = value or None
return values
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
@@ -105,7 +96,11 @@ def get_ui_settings(
Reads only: a missing row means "defaults" (the env strings + the
built-in palette), so a fresh deployment's tab shows the live theme
with an empty row, and nothing is ever upserted by a read.
with an empty row, and nothing is ever upserted by a read. The 3
strings report the EFFECTIVE values through the phase-190 order
(panel row → this row → env), so an old client's form pre-populates
with what the deployment actually shows, never with this row's stale
bake.
"""
return UiSettingsOut(**theming.effective_settings(db, settings))
@@ -116,24 +111,30 @@ def update_ui_settings(
settings: Settings = Depends(get_settings), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> UiSettingsOut:
"""Replace the single row with the body's 20 values (validated and
"""Replace the row's 17 COLORS with the body's (validated and
normalized — see the module docstring), then report the new
effective values.
The body's 3 brand strings are NEVER read (phase 190, A1: the admin
Settings panel is their single writable owner). Dropping them here
rather than rejecting them keeps an old cached client's Save working
— and takes away the mechanism of the ``TODO.md`` L3 incident: the
row's string columns keep exactly what they hold (never written, so
a legacy baked value survives as the documented fallback, and no
save can re-bake the then-effective name and shadow every later
panel change).
Upsert on the id-1 row (SELECT → update-or-insert; the Python-side
``default=1`` supplies the PK on the insert). Concurrency is
single-admin (one owner, one tab) — the last writer wins and no
lock is taken: a lost race just means the other admin's PUT is the
effective one.
"""
strings = _validate_strings(payload)
colors = _validate_colors(payload)
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
if row is None:
row = UiSettings(id=1)
db.add(row)
for field in theming.STRING_FIELDS:
setattr(row, field, strings[field])
for field in theming.COLOR_FIELDS:
setattr(row, field, colors[field])
db.commit()
+39
View File
@@ -32,6 +32,12 @@ pre-migration deployment is a normal state); any other DB failure is
one WARNING per process naming the exception class ("settings
overrides unavailable — env values in effect"). Both "once per process"
guards are module-level flags, reset by :func:`reset_state_for_tests`.
Phase 190 (task 02) adds the one question another resolver asks this
table: :func:`applied_override_keys` — "which keys does the Settings
panel own right now?" (the brand-string ownership order in
:func:`app.core.theming.effective_settings`). It reads on the CALLER's
session, so answering it costs no second engine and never a stale view.
"""
from __future__ import annotations
@@ -45,6 +51,7 @@ from pydantic import ValidationError
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from app.config import Settings
@@ -125,6 +132,38 @@ def load_overrides() -> dict[str, str]:
return {}
def applied_override_keys(session: Session) -> frozenset[str]:
"""The ``app_settings`` keys IN EFFECT right now — phase 190 task 02.
:func:`app.core.theming.effective_settings` needs exactly this to
answer "does the admin's Settings panel own this brand string?" (A1:
the panel row beats the Theme-tab row, and only a REAL row counts —
comparing values would mis-read a panel row that happens to equal
the env value, the exact shadow the phase-190 incident is).
Read on the CALLER's session — one ``SELECT`` on a connection the
caller already holds, transactionally consistent with the
``ui_settings`` read beside it (a cross-request memo of the key set
would go stale the moment a row is written outside the panel and
could re-shadow a just-saved value). The resolver calls this ONCE
per resolution and tests the three string fields for membership, so
a resolution costs no second query per field.
The kill switch (``BOR_SETTINGS_DB``, D6) short-circuits before any
read — with the overlay off no row is in effect, and the Theme-tab
row keeps its phase-91 fallback. A non-overridable key (LOCKED A3)
and an orphan row (a field a later release removed) are never
"in effect": :func:`merge` drops them, so they must not win here
either.
"""
if not enabled():
return frozenset()
keys = session.execute(text("SELECT key FROM app_settings")).scalars().all()
return frozenset(
key for key in keys if key in Settings.model_fields and CATALOG[key].overridable
)
def _coerce(field_name: str, raw: str) -> Any:
"""Coerce one env-native string row value for the candidate set.
+49 -11
View File
@@ -83,12 +83,28 @@ diverge (``tests/unit/test_theming.py`` pins the mirror: every
pairs).
Effective-value resolution (:func:`effective_settings`) — the DB-over-
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
color column to the built-in. ONE resolver is used by BOTH
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
layer), so the tab and the running UI can never disagree.
env / DB-over-built-in merge (B1, owner-locked 2026-09-09) resolved
column-by-column:
* **the three brand strings** (``app_name`` / ``input_placeholder`` /
``footer_text``) — **Settings-panel row → Theme-tab row → env**
(phase 190, A1 owner-confirmed 2026-10-18). The panel (the
``app_settings`` rows behind ``/api/admin/settings``, phase 168) is
the SINGLE writable owner of the strings, so its row wins whenever it
exists; the Theme-tab row (this module's ``ui_settings``) is the
documented fallback for the rows it baked before this phase (no data
migration — a legacy row keeps rendering what it holds until the
panel overrides it); the ``BOR_`` env value is the last resort.
Presence, never value comparison: an ``app_settings`` row that
happens to equal the env value still owns the field (that equality is
exactly how the phase-190 shadow looked from the owner's side).
* **the 17 colors** — unchanged (B1): the ``ui_settings`` row when not
``NULL``, else the built-in palette. The ownership flip is
strings-only.
ONE resolver is used by BOTH ``GET /api/ui-settings`` (the tab) and
``GET /api/config`` (the brand layer), so the tab and the running UI
can never disagree.
"""
from __future__ import annotations
@@ -99,6 +115,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.core import settings_overlay
from app.models import UiSettings
#: The 17 built-in palette colors, keyed by BARE variable name (no
@@ -136,8 +153,12 @@ BUILTIN_COLORS: dict[str, str] = {
#: API, and the ``theme_style_tag`` renderer (task 02).
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
#: The 3 display strings the ``ui_settings`` row carries — env fallback
#: (B1: unlike the colors, the env vars stay the strings' default).
#: The 3 brand strings the ``ui_settings`` row carries. Since phase 190
#: (A1) the Settings panel (``app_settings``, phase 168) is their single
#: writable OWNER and wins over this row; the row is the documented
#: fallback for the values it baked in before that phase, with the env
#: value last (B1: unlike the colors, the env vars stay the default).
#: ``PUT /api/ui-settings`` no longer writes them.
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
@@ -150,12 +171,22 @@ def effective_settings(
defaults, column by column:
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
— the DB value when it is a non-empty string, else the env value
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
— the three-way order of phase 190 (A1), per field:
**Settings-panel row** → **Theme-tab row** → **env**. The panel
row is ``getattr(settings, field)`` (the ``Settings`` the route
passes already resolves through the phase-168 overlay, so a panel
row IS its value) and it wins only when the overlay reports the
key as an actual override
(:func:`app.core.settings_overlay.applied_override_keys`, read
ONCE here — presence, never a value compare). Otherwise the
``ui_settings`` value when it is a non-empty string (the phase-91
fallback a legacy baked-in row still lands in), else the env
value (B1: the env vars stay the last resort).
* **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8
semantic state) — the DB value when not ``None``, else
:data:`BUILTIN_COLORS` (B1: no env fallback for colors — the
built-in palette IS the default).
built-in palette IS the default; the ownership flip is
strings-only).
A missing row (``GET`` creates nothing) means "defaults" — the env
strings + the built-in palette. The ``settings`` parameter names the
@@ -167,8 +198,15 @@ def effective_settings(
if settings is None:
settings = get_settings()
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
#: The keys the admin's Settings panel owns right now (phase 190
#: A1) — one SELECT on this same session, consulted per string
#: field below with a membership test (no query per field).
panel_keys = settings_overlay.applied_override_keys(session)
effective: dict[str, str] = {}
for field in STRING_FIELDS:
if field in panel_keys:
effective[field] = getattr(settings, field)
continue
value = getattr(row, field, None) if row is not None else None
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
for key in COLOR_FIELDS:
+11 -1
View File
@@ -563,6 +563,14 @@ class UiSettings(Base):
overview). :func:`app.core.theming.effective_settings` resolves the
effective 20 values both the ``GET /api/ui-settings`` and
``GET /api/config`` endpoints serve.
Phase 190 (task 02, A1 — owner-confirmed 2026-10-18) narrowed the
WRITE half of this row to the 17 colors: the three string columns
are now the admin Settings panel's (``app_settings``, phase 168) and
``PUT /api/ui-settings`` never touches them. The columns stay — a
row this table baked before that phase is the documented FALLBACK
(panel row → this row → env) and still renders until the panel
overrides it, so the fix needed no data migration.
"""
__tablename__ = "ui_settings"
@@ -571,7 +579,9 @@ class UiSettings(Base):
#: id=1 precedent — Python-side default; the migration carries no
#: server default because the row is created only by the PUT upsert).
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
# --- Strings (NULL/empty = "use the env default" — B1) ---
# --- Strings — READ-ONLY since phase 190 (A1): the Settings panel's
# row wins over these, the Theme-tab PUT never writes them, and
# NULL/empty means "use the env default" (B1) ---
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True)
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
+20 -14
View File
@@ -2077,20 +2077,24 @@ class TokenAuthRequest(BaseModel):
class UiSettingsIn(BaseModel):
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
replacement of the single ``ui_settings`` row.
"""``PUT /api/ui-settings`` body (phase 91, task 01; colors-only since
phase 190, task 02): a FULL replacement of the single ``ui_settings``
row's 17 COLORS.
Every field is ``str | None`` — present = a new value (strings are
trimmed; empty after the trim is the CLEAR operation, stored as
NULL; colors must be ``#rrggbb`` and are lowercased on store),
``null``/absent = "back to the default" (stored as NULL — the Reset
button's all-null PUT is exactly the "defaults" operation). The
API layer runs the trim/length/hex validation so the 422 details
name the offending field (the house fixed-detail style); the
built-in→NULL normalization (a color equal to its built-in is
stored as NULL — "save the defaults" must leave the row empty, the
no-op injection contract) happens there too, next to the palette
it normalizes against.
Every field is ``str | None`` — present = a new value, ``null``/
absent = "back to the default" (stored as NULL — the Reset button's
all-null PUT is exactly the "defaults" operation). The API layer
runs the hex validation so the 422 details name the offending field
(the house fixed-detail style); the built-in→NULL normalization (a
color equal to its built-in is stored as NULL — "save the defaults"
must leave the row empty, the no-op injection contract) happens
there too, next to the palette it normalizes against.
The three string fields stay on the model for OLD-CLIENT COMPAT and
are ACCEPTED AND IGNORED (phase 190 A1): the Settings panel owns the
brand strings, so the handler drops them before validating or
persisting and the stored columns keep what they hold. Resetting a
string is ``DELETE /api/admin/settings/{key}``.
"""
app_name: str | None = None
@@ -2127,7 +2131,9 @@ class UiSettingsOut(BaseModel):
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
the LIVE theme — a fresh (row-missing) deployment reports the env
strings and the built-in palette (the semantic colors report their
built-ins — the effective values, the NULL = built-in rule).
built-ins — the effective values, the NULL = built-in rule). Since
phase 190 (A1) the 3 strings are READ-ONLY here and report the
EFFECTIVE value through the panel-row → tab-row → env order.
"""
app_name: str