"""app_settings: admin DB-side overrides for any BOR_* setting (phase 168, task 01) Revision ID: 0028 Revises: 0027 Create Date: 2026-10-09 ``TODO.md`` L3: "I want an admin panel that can edit anything in .env and save it to the database. The database value should override the env var. The change should take effect on clicking 'save' on that page." This revision carries the storage half of that (phase 168 D1) — the resolver (D2) and the live-apply hook (D3) are task 02/03. * ``app_settings`` — one row per admin setting override, the whole table a narrow key/value pair: * ``key`` — VARCHAR(100) NOT NULL, PRIMARY KEY. The BARE ``Settings`` field name (``relevance_threshold``) — never the ``BOR_…`` form: the prefix is a presentation concern (``"BOR_" + key.upper()``), so a stored prefix could double up. The PK is the uniqueness guard: two rows for one setting would make "which one wins" undefined, and Postgres enforces it for free (the ``ui_settings`` single-row shape is not an option here — there are 75 settings, and a wide row would need per-field DDL for every new one, defeating "edit ANYTHING in .env"). * ``value`` — TEXT NOT NULL. The value in the SAME textual form the env var would carry (``"0.55"``, ``"true"``, ``"md,txt"``, a JSON array for the one ``list[str]`` field). Storing the env- native string — not a typed column — keeps coercion and validation in exactly one place (the pydantic validators the overlay runs the full candidate set through, phase 168 D1/A5), and makes "anything in .env" work for all 75 settings with zero per-field DDL. NOT NULL on purpose: "clear the override" is a DELETE of the row (the panel's per-row Reset / Clear all), never an empty value, so the resolver never has to distinguish "no override" from "override to empty". * ``updated_at`` — TIMESTAMPTZ NOT NULL, ``now()`` server default (the ``folder_summaries`` / ``kb_overview.updated_at`` house style): when the admin last saved the override. No index beyond the PK: the whole table is read by primary key order in ONE ``SELECT`` per settings resolution (the ``ui_settings`` "display fact, never a query surface" note, 0027). One additive, fully reversible migration (A13). """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "0028" down_revision = "0027" branch_labels = None depends_on = None def upgrade() -> None: # No index beyond the PK (a display fact, never a query surface — # the whole table is one SELECT per resolution) and no server # default on ``value`` (NOT NULL — the reset is a DELETE). op.create_table( "app_settings", sa.Column("key", sa.String(length=100), primary_key=True), sa.Column("value", sa.Text(), nullable=False), sa.Column( "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), ) def downgrade() -> None: # The table is the only 0028 artefact — dropping it leaves 0027's # schema byte-identical (A13, fully reversible). No data is lost # by the drop in any meaningful sense: the values are re-enterable # from the panel (they are an override of the env, not a source of # truth — the env file stays the base, LOCKED A1). op.drop_table("app_settings")