Ctrl K
ComunidadGuías…

config.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Ali Sadeghi Aghili
"""Runtime configuration — all values read from environment / .env.

Never hardcode credentials here.
Copy .env.example → .env and fill in real values.

Usage::

    import config as cfg
    print(cfg.settings.openai_model)

Testing::

    Use ``override_settings()`` to safely replace the singleton in tests.
    Because every consumer accesses ``cfg.settings`` at call-time (not at
    import-time), the patch is visible to ALL modules immediately.

        with override_settings(max_rows_returned=5):
            ...  # every cfg.settings access sees the patched value

Three layers, not two
----------------------
This codebase separates three kinds of thing that are easy to lump
together into "configuration":

1. **Engine** — the code itself (``api/``, ``llm/``, ``retrieval/``,
   ``security/``, ...). No domain names, no tuning literals: it must work
   unmodified against any warehouse.
2. **Domain data** — what a *particular* warehouse *is*: its tables,
   columns, business rules, aliases, few-shot examples
   (``project_config/*.yaml``, loaded by :mod:`knowledge.config_loader`
   and :mod:`schema_data.registry`). Swapping warehouses means swapping
   this directory, never editing engine code.
3. **Tuning** — how aggressively the engine retrieves, matches, caches,
   and retries against *this* warehouse and *this* hardware. This module.

A module-level numeric constant elsewhere in the source is not
automatically "tuning" just because layer 3 exists. Sorting a candidate
constant into one of three buckets:

* **Tuning** — a knob an operator might legitimately want different for
  their warehouse or their hardware (a bigger cache, a longer timeout, a
  looser regression tolerance on slower CI hardware, ...). Becomes a
  ``Settings`` field: ``field(default_factory=lambda: os.getenv(...))``,
  documented, read through ``cfg.settings`` at call time so
  ``override_settings()`` reaches it in tests. See
  ``eval_max_accuracy_drop_pct`` / ``eval_max_latency_p95_increase_pct`` /
  ``eval_max_guard_rejection_increase`` below for an example promoted out
  of ``eval/baseline.py`` under exactly this reasoning: how much
  latency/accuracy regression is tolerable is a property of *this*
  deployment's hardware and traffic, not a fixed technical fact.
* **Invariant** — a value that is part of the design's *correctness* and
  must not be tuned, even though it happens to be a number. Stays a
  source constant, with a docstring saying why it is deliberately not
  configurable. The clearest example is
  :data:`security.auth.MIN_KEY_LENGTH` (32): lowering it weakens a
  security property (structural entropy enforced once, at key-issue
  time), so exposing it as an env-overridable knob would let a deployment
  quietly weaken its own auth by setting one variable. Also in this
  bucket: :data:`eval.determinism.MIN_REPEATS` (a statistical floor —
  fewer than 2 repeats cannot measure determinism at all, it is not a
  "less thorough" setting) and :data:`eval.fingerprint.DEFAULT_FLOAT_PRECISION`
  (a golden set's ``expected_fingerprint`` values are hashed at a fixed
  precision; making this env-tunable would silently desynchronise a
  deployment's environment from its own golden set's pinned hashes,
  turning "regression" into "someone's shell profile" with no error at
  either end).
* **Implementation detail** — a value nobody outside the module cares
  about, or one whose only meaningful comparison is against itself /
  against a value already tunable elsewhere. Stays put, no docstring
  justification required beyond the ordinary one.
  :data:`prompt_engine.static_prefix._CHARS_PER_TOKEN` lives here: it is
  a rough characters-per-token heuristic (no real tokenizer dependency),
  used only in comparisons against itself and against
  :attr:`prompt_retrieval_token_budget` below — a deployment whose text
  tokenizes at a different real ratio (e.g. Persian vs English) already
  has the one knob it needs in :attr:`prompt_retrieval_token_budget`;
  giving the ratio its own env var would add a second dial over the same
  effective threshold rather than a genuinely independent one.
  :data:`database.schema_inspector._MAX_SAMPLE_LEN` is the same kind of
  thing one level further out: a display-truncation length inside the
  interactive, untested, coverage-excluded setup wizard
  (``setup_project.py``), never read by the running engine at all.

This rule is enforced, not just documented: ``tests/test_tuning_layer.py``
walks first-party source for a *new* bare module-level numeric constant
outside its allowlist and fails the build, the same way
``tests/test_persian_normalization.py`` already does for a second
translation table built the same way ``core.persian`` builds its own.
See that new test's module docstring for exactly which existing constants
are allowlisted and why — several are legitimately tuning-shaped but
already have a more appropriate, narrower configuration surface than a
process-wide environment variable (e.g. the eval CLI's own
``--determinism-repeats`` flag; see :data:`eval.determinism.DEFAULT_REPEATS`)
and were deliberately left alone rather than duplicated into ``Settings``.
"""

from __future__ import annotations

import os
import sys
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any, Generator

# ---------------------------------------------------------------------------
# .env loading — must happen before Settings() is ever constructed below,
# so that os.getenv() calls in the field default_factories see values from
# .env.  This is the single, earliest point every entry point (app.py,
# api/server.py, tests) goes through, since they all ``import config``.
#
# python-dotenv is a required dependency (see requirements.txt), but we
# degrade gracefully instead of crashing the whole application if it is
# ever missing from an environment (e.g. a minimal container image that
# only sets real environment variables and has no .env file at all).
# ---------------------------------------------------------------------------
#: The ``.env`` file python-dotenv loaded, and what it could not use in it
#: (:func:`core.dotenv_check.scan_dotenv`). Empty path = no ``.env`` file.
#: Set once, below; :meth:`Settings.validate` refuses to start on any problem
#: because python-dotenv itself only prints a one-line warning to stderr.
_dotenv_path: str = ""
_dotenv_problems: list[DotenvProblem] = []
#: ``PYTHON_DOTENV_DISABLED`` was set, so no ``.env`` was searched for or loaded.
_dotenv_disabled: bool = False
#: python-dotenv itself could not be imported, so no ``.env`` was read at
#: all. Set once, below.
_dotenv_missing: bool = False
#: python-dotenv is installed but :mod:`core.dotenv_check` could not be
#: imported: ``.env`` is still loaded, only not scanned or explained.
_dotenv_scan_unavailable: bool = False


def _dotenv_search_start() -> str:
    """The folder python-dotenv's ``find_dotenv()`` starts searching from.

    ``find_dotenv()`` starts at the folder of the file that called it, and
    walks up from there. It starts at the current working directory instead
    when it is called interactively (``sys.ps1`` / ``sys.ps2`` exist, or
    ``__main__`` has no ``__file__``), under a debugger or tracer
    (``sys.gettrace()`` is not ``None``), or from a frozen executable
    (``sys.frozen``). These are the same conditions, and this must be called
    from this module's top level at the moment ``find_dotenv()`` is, so that
    "the file that called it" is this file.

    Returns
    -------
    str
        The folder the search starts from; the message that names the folders
        searched for a ``.env`` reports it.

    Examples
    --------
    >>> import os
    >>> _dotenv_search_start() in (os.getcwd(), os.path.dirname(os.path.abspath(__file__)))
    True
    """
    interactive = hasattr(sys, "ps1") or hasattr(sys, "ps2")
    if not interactive:
        try:
            main = __import__("__main__", None, None, fromlist=["__file__"])
        except ModuleNotFoundError:  # pragma: no cover - python-dotenv treats it as not interactive
            main = None
        interactive = main is not None and not hasattr(main, "__file__")
    if interactive or sys.gettrace() is not None or getattr(sys, "frozen", False):
        return os.getcwd()
    return os.path.dirname(os.path.abspath(__file__))


#: The folder ``find_dotenv()`` actually started from (see
#: :func:`_dotenv_search_start`); recorded at import, next to the search.
_dotenv_search_dir: str = os.path.dirname(os.path.abspath(__file__))

try:
    from dotenv import find_dotenv, load_dotenv
except ImportError:  # pragma: no cover - exercised only when dependency missing
    _dotenv_missing = True
else:
    try:
        from core.dotenv_check import DotenvProblem, scan_dotenv
    except ImportError:  # pragma: no cover - python-dotenv is there; the scan is not
        _dotenv_scan_unavailable = True
    # find_dotenv() here, not inside load_dotenv(): it searches from the
    # calling file's directory, and the scan must read the same file.
    # PYTHON_DOTENV_DISABLED turns loading off, and with it the check.
    _dotenv_disabled = os.environ.get("PYTHON_DOTENV_DISABLED", "").casefold() in (
        "1", "true", "t", "yes", "y",
    )
    if not _dotenv_disabled:
        _dotenv_search_dir = _dotenv_search_start()
        _dotenv_path = find_dotenv()
        if not _dotenv_scan_unavailable:
            _dotenv_problems = scan_dotenv(_dotenv_path)
    load_dotenv(_dotenv_path or None)


def _dotenv_missing_sentence() -> str:
    """The one sentence for an environment without python-dotenv."""
    return (
        f"python-dotenv is not installed in this Python environment ({sys.executable}), "
        "so .env was not read; install the requirements into it: "
        "pip install -r requirements.lock"
    )


def _dotenv_unscanned_sentence(variable: str) -> str:
    """The one sentence when python-dotenv loaded ``.env`` but it cannot be inspected."""
    where = f"{_dotenv_path} was loaded" if _dotenv_path else "no .env file was found"
    return (
        f"{where}, but core.dotenv_check could not be imported, so it was not "
        f"inspected; check that the .env python-dotenv loads assigns {variable}"
    )


def explain_unset_variable(variable: str) -> list[str]:
    """Why *variable*, expected from ``.env``, is unset or empty in this process.

    For a message that otherwise only says "not set or is empty": which
    ``.env`` was loaded (or that none was found, and the folder the search
    started from), what python-dotenv could not use in it, and whether the
    file assigns the variable at all. See :func:`core.dotenv_check.explain_unset`.

    This is only ever an addition to an error that is already being raised,
    so it never raises: any failure while building it yields ``[]``.

    Parameters
    ----------
    variable:
        The environment variable that was needed.

    Returns
    -------
    list[str]
        Sentences naming files, lines and variables, never a value. When
        python-dotenv is not installed in this environment, the single
        sentence saying so (it names the interpreter), because then ``.env``
        was never read. When python-dotenv loaded ``.env`` but
        :mod:`core.dotenv_check` cannot be imported, one sentence saying the
        file was not inspected. ``[]`` if the explanation itself failed.

    Examples
    --------
    >>> import config
    >>> lines = config.explain_unset_variable("NO_SUCH_VARIABLE_FOR_THE_DOCTEST")
    >>> isinstance(lines, list)
    True
    """
    try:
        if _dotenv_missing:
            return [_dotenv_missing_sentence()]
        if _dotenv_scan_unavailable:
            return [_dotenv_unscanned_sentence(variable)]
        try:
            from core.dotenv_check import explain_unset
        except ImportError:
            return [_dotenv_unscanned_sentence(variable)]
        return explain_unset(
            variable,
            _dotenv_path,
            _dotenv_problems,
            os.environ,
            # The folder find_dotenv() really searched from: not always this file's.
            _dotenv_search_dir,
            disabled=_dotenv_disabled,
        )
    except Exception as exc:  # noqa: BLE001 - never mask the error being explained
        import logging

        # The exception type only: its text could quote a .env line.
        logging.getLogger(__name__).debug(
            "could not explain why %s is unset (%s)", variable, type(exc).__name__
        )
        return []


#: Origins the bundled ``web/`` UI is documented to be served from, and the
#: default for :attr:`Settings.cors_allowed_origins`.
#:
#: This used to default to empty -- the most restrictive choice, and the
#: wrong one, because the layout this project *documents* is the API on one
#: port and the static UI on another. A browser on 8080 calling 8000 is
#: cross-origin, so the out-of-the-box experience was a preflight answered
#: "400 Disallowed CORS origin", which every browser reports to the page as
#: the uninformative "Failed to fetch". The UI showed API, LLM and DB all
#: down while the CLI, which is not a browser and never sends an Origin,
#: worked perfectly against the same server.
#:
#: Loopback only, and that is what makes it safe to default: a page cannot
#: choose the ``Origin`` the browser sends, so allowing these helps only a
#: page genuinely served from this machine's own 8080 -- and every route it
#: could then reach still requires an API key. Anything else, including any
#: non-loopback host, still has to be named explicitly.
DEFAULT_CORS_ALLOWED_ORIGINS: tuple[str, ...] = (
    "http://localhost:8080",
    "http://127.0.0.1:8080",
)


def _parse_port(env_var: str, default: str) -> int:
    """Parse *env_var* (falling back to *default*) as a TCP port number,
    raising a message that names the variable and the bad value instead
    of letting a bare ``int(...)`` ``ValueError`` -- "invalid literal for
    int() with base 10: '...'", which names neither -- propagate out of a
    ``default_factory`` and crash ``import config`` with nothing an
    operator could act on without already knowing this module's
    internals."""
    raw = os.getenv(env_var, default)
    try:
        port = int(raw)
    except ValueError:
        raise ValueError(f"{env_var} must be an integer (got {raw!r})") from None
    if not (1 <= port <= 65535):
        raise ValueError(f"{env_var} must be between 1 and 65535 (got {port})")
    return port


def _parse_optional_percent(env_var: str) -> float | None:
    """Parse *env_var* as an optional percentage in ``[0, 100]``.

    An unset or blank variable means "not configured" and yields ``None``
    (the feature stays off). A set variable that is not a number, or lies
    outside ``0``-``100``, raises a message naming the variable and the bad
    value rather than a bare ``float()`` error, for the same reason as
    :func:`_parse_port`.

    Args:
        env_var: Name of the environment variable to read.

    Returns:
        The percentage as a float, or ``None`` when unset or blank.

    Raises:
        ValueError: If the value is not a finite number between 0 and 100.

    Examples:
        >>> import os
        >>> os.environ.pop("_DEMO_PCT", None) is None
        True
        >>> _parse_optional_percent("_DEMO_PCT") is None
        True
        >>> os.environ["_DEMO_PCT"] = " 92.5 "
        >>> _parse_optional_percent("_DEMO_PCT")
        92.5
        >>> os.environ["_DEMO_PCT"] = "120"
        >>> _parse_optional_percent("_DEMO_PCT")
        Traceback (most recent call last):
            ...
        ValueError: _DEMO_PCT must be a percentage between 0 and 100 (got '120')
        >>> del os.environ["_DEMO_PCT"]
    """
    raw = os.getenv(env_var, "").strip()
    if not raw:
        return None
    try:
        value = float(raw)
    except ValueError:
        value = float("nan")
    if not (0.0 <= value <= 100.0):  # also rejects nan
        raise ValueError(
            f"{env_var} must be a percentage between 0 and 100 (got {raw!r})"
        )
    return value


def _has_placeholder_login_host(url: str) -> bool:
    """Whether *url* still names ``username@server``, the factory default.

    The literal text is matched, and so is the parsed login and host: once
    ``DB_PASSWORD`` is set on the URL it reads ``username:<password>@server``,
    which the literal text no longer matches.

    Examples
    --------
    >>> _has_placeholder_login_host("mssql+pyodbc://username@server:1433/db")
    True
    >>> _has_placeholder_login_host("mssql+pyodbc://username:pw@server/db")
    True
    >>> _has_placeholder_login_host("mssql+pyodbc://nlq:pw@db1/db")
    False
    """
    if "username@server" in url.lower():
        return True
    from sqlalchemy.engine import make_url

    try:
        parsed = make_url(url)
    except Exception:  # noqa: BLE001 - an unparsable URL has no login to compare
        return False
    return (parsed.username or "").lower() == "username" and (parsed.host or "").lower() == "server"


def _unset_variable_error(message: str, variable: str) -> ValueError:
    """An :class:`~database.datasources.UnsetVariableError` (imported late: it imports this module)."""
    from database.datasources import UnsetVariableError

    return UnsetVariableError(message, variable)


def _check_warehouse_url(label: str, url: str, dialect: str, placeholders: set[str]) -> None:
    """Refuse an unset, placeholder or wrong-dialect warehouse connection string.

    Two independent placeholder checks apply: an exact match against
    *placeholders* (single unfilled tokens such as ``"change_me"``) and a
    substring check for ``"username@server"``, the literal host baked into
    :attr:`Settings.db_connection_url`'s factory default. The default is a
    *full connection string*, not a bare token, so it never equals any
    entry in *placeholders* and used to pass validation silently whenever
    a user copied ``.env.example`` and forgot to fill in the variable.

    The dialect check catches the "set SQL_DIALECT but forgot to update the
    connection string" misconfiguration -- a mismatch means every query
    would fail at execution (or be silently misinterpreted). It parses the
    URL string only (``sqlalchemy.engine.make_url`` needs no driver import
    and opens no connection); an exotic backend with no sqlglot-dialect
    mapping is not an error on its own and is skipped.

    Parameters
    ----------
    label:
        Environment variable the URL came from, used in the message.
    url:
        The connection string.
    dialect:
        The sqlglot dialect this connection must speak.
    placeholders:
        Unfilled-token values to refuse.

    Raises
    ------
    ValueError
        Naming *label* and what is wrong with it.
    database.datasources.UnsetVariableError
        (A :class:`ValueError`.) *label* is an environment variable name and
        the variable is unset or empty -- the URL is empty, or it is the
        factory-default placeholder because ``DB_CONNECTION_URL`` is not in
        the environment. The message is the same; the exception carries the
        variable so :meth:`Settings.validate` can say why it is missing.
    """
    if not url or url in placeholders:
        message = f"{label} is not configured"
        if not url and label.isidentifier():
            raise _unset_variable_error(message, label)
        raise ValueError(message)
    if _has_placeholder_login_host(url):
        message = (
            f"{label} still has the factory-default placeholder "
            "host (username@server) — set a real connection string in .env"
        )
        if label.isidentifier() and not os.environ.get(label):
            raise _unset_variable_error(message, label)
        raise ValueError(message)

    from sqlalchemy.engine import make_url

    from security.dialects import sqlglot_dialect_for_backend

    try:
        backend_name = make_url(url).get_backend_name()
        expected_dialect = sqlglot_dialect_for_backend(backend_name)
    except Exception:  # noqa: BLE001 - an unrecognised backend is a
        # different, unrelated concern -- not something this
        # SQL_DIALECT/connection-string consistency check can judge.
        expected_dialect = None
    if expected_dialect is not None and expected_dialect != dialect:
        raise ValueError(
            f"SQL_DIALECT={dialect!r} does not match "
            f"{label}'s backend ({backend_name!r}, which "
            f"this deployment targets as {expected_dialect!r}) -- "
            "every query would fail at execution against the wrong "
            f"dialect. Set SQL_DIALECT to match {label}, or "
            f"fix {label} to point at the intended database."
        )


@dataclass(frozen=True, slots=True)
class Settings:
    """Immutable runtime settings resolved from environment variables."""

    # ── LLM provider (OpenAI-compatible endpoint only) ──────────────────
    openai_base_url: str = field(
        default_factory=lambda: os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
    )
    """Base URL of the single, trivial-case OpenAI-compatible endpoint
    (:class:`~llm.endpoints.EndpointConfig` named ``"default"``) — a local
    ``gpt-oss``/vLLM/llama.cpp server, or OpenAI's own hosted API. See
    ``LLM_ENDPOINTS``/``LLM_ROUTES`` (:attr:`llm_endpoints_json` /
    :attr:`llm_routes_json`) for routing across more than one endpoint."""

    openai_model: str = field(
        default_factory=lambda: os.getenv("OPENAI_MODEL", "gpt-4o-mini")
    )
    """Model tag sent to :attr:`openai_base_url`."""

    openai_api_key: str = field(
        default_factory=lambda: os.getenv("OPENAI_API_KEY", "")
    )
    """Bearer token for :attr:`openai_base_url`. Empty is valid — many
    self-hosted OpenAI-compatible servers don't check it."""

    # ── HTTP server binding (``python -m api`` launcher) ────────────────
    # The API port has always been settable on the `uvicorn` command line
    # (`--port`) but never in `.env` -- an operator who only ever edits
    # `.env` (the documented workflow for every other setting on this
    # page) had no way to change it there, and started the server on the
    # default port while the rest of their `.env` assumed a different one.
    # These two exist so `.env` is a complete description of where the
    # server binds, and `python -m api` (this module's own `__main__.py`)
    # is a launcher that actually reads them -- the plain `uvicorn ...`
    # command in the runbook/README keeps working unmodified either way,
    # since uvicorn itself never reads these variables.
    api_host: str = field(default_factory=lambda: os.getenv("API_HOST", "127.0.0.1"))
    """Interface ``python -m api`` binds to. Defaults to the loopback-only
    ``127.0.0.1`` -- a freshly-cloned checkout should not be reachable
    from the network until an operator deliberately widens it. The
    documented deployment command binds ``0.0.0.0`` (every interface, see
    ``docs/deployment-runbook.md`` step 4) explicitly, on the command
    line, precisely because that choice should be visible at the call
    site rather than silently inherited from a default an operator never
    looked at."""

    api_port: int = field(default_factory=lambda: _parse_port("API_PORT", "8000"))
    """Port ``python -m api`` binds to. ``8000`` matches every other
    default in this codebase that assumes the API is reachable at
    ``http://localhost:8000`` (``web/js/config.js``'s ``DEFAULT_API_PORT``,
    ``DEFAULT_CORS_ALLOWED_ORIGINS`` above's counterpart on the UI side,
    the runbook, the README). Changing it here changes what ``python -m
    api`` binds to; a plain ``uvicorn ...`` invocation is unaffected --
    its port comes only from its own ``--port`` flag.

    A non-integer or out-of-range (outside 1-65535) ``API_PORT`` fails
    ``import config`` immediately with a message naming ``API_PORT`` and
    the offending value (see :func:`_parse_port`) rather than a bare
    ``int()`` traceback that names neither."""

    log_level: str = field(default_factory=lambda: os.getenv("LOG_LEVEL", "INFO"))
    """Level (``"DEBUG"``/``"INFO"``/``"WARNING"``/...) the application's
    own loggers emit at, applied to the ROOT logger by
    :func:`core.logging_setup.configure_stdlib_logging` -- called once,
    early, by both ``api/server.py``'s ``lifespan`` and ``python -m api``
    (``api/__main__.py``) -- but only when nothing has configured logging
    yet (root has no handler). Exists because neither documented way of
    starting the HTTP API otherwise makes an ``INFO`` line -- the startup
    provenance banner, the CORS-allowlist line -- reach anywhere at all:
    uvicorn's own default logging config never touches the root logger,
    which Python itself starts at ``WARNING`` with no handler. See
    ``core.logging_setup``'s module docstring for the full mechanism."""

    db_connection_url: str = field(
        default_factory=lambda: os.getenv(
            "DB_CONNECTION_URL",
            "mssql+pyodbc://username@server:1433/Auction_DM"
            "?driver=ODBC+Driver+17+for+SQL+Server&trusted_connection=yes",
        )
    )
    """The single warehouse connection, used only when there is no
    ``project_config/datasources.yaml``. Any password written inside it must
    be percent-encoded (``@`` as ``%40``); to avoid that, leave the password
    out and give it raw in :attr:`db_password`."""

    db_password: str = field(
        default_factory=lambda: os.getenv("DB_PASSWORD", ""),
        repr=False,
    )
    """The raw password for :attr:`db_connection_url` (``DB_PASSWORD``), with
    no URL encoding. Empty (the default) leaves the URL exactly as written.
    When set, the URL must carry a login but no password: it is set on the
    parsed URL by :func:`database.datasources.apply_db_password`, and a
    password present in both places is refused at start-up. Ignored when
    ``datasources.yaml`` exists (each source there names its own
    ``password_env``). Excluded from ``repr`` so it never reaches a log."""
    db_pool_pre_ping: bool = field(
        default_factory=lambda: os.getenv("DB_POOL_PRE_PING", "true").lower()
        in ("1", "true", "yes")
    )
    """Whether :func:`database.connection.get_engine` (and, for a
    non-SQLite backend, :func:`appdb.engine.build_engine`) installs the
    idle-aware pre-ping described under :attr:`db_pool_ping_idle_seconds`
    below (Finding 1, 2026 warehouse-load audit, revised by the
    idle-aware-ping follow-up). Defaults to ``True``. ``False`` means
    exactly what it always has: never ping a pooled connection on
    checkout, full stop — :attr:`db_pool_ping_idle_seconds` has no effect
    at all when this is off.

    What it trades off
    -------------------
    **On (default).** A connection that has sat idle in the pool for at
    least :attr:`db_pool_ping_idle_seconds` is probed with ``SELECT 1``
    before being handed to the caller; one that has been used more
    recently than that is handed over unprobed. A connection that has
    gone stale (the SQL Server side closed it, a firewall/load-balancer
    idle-timed it out) is discovered and replaced before the caller's
    real query ever sees it, instead of surfacing as a query failure —
    real safety, which is why the default stays on. Unlike the
    unconditional ``pool_pre_ping=True`` this setting used to map to
    one-for-one, the probe cost now scales with how often a connection
    goes idle for that long, not with every single checkout — see
    :attr:`db_pool_ping_idle_seconds` for the threshold and the
    ``DB_POOL_PING_IDLE_SECONDS=0`` escape hatch back to today's
    ping-every-checkout behaviour.

    **Off.** No probe on checkout, ever, regardless of idle time. A
    connection that went stale while idle in the pool is only discovered
    when a real query is sent through it, which then fails once and is
    transparently retried on a fresh connection (SQLAlchemy's own pool
    invalidate-and-retry behaviour on a disconnect-class error) — one
    query pays a one-time retry cost instead of any checkout paying a
    probe cost.

    **Why turning it off is a reasonable choice here, not just a
    trade-off**: with :attr:`db_pool_recycle_seconds` (below) set well
    under whatever idle timeout the network path (SQL Server itself, a
    firewall, a load balancer) actually enforces, a connection is
    proactively recycled before it would ever go stale from sitting idle
    — the failure this setting exists to catch becomes rare rather than
    routine. Turn this off only after confirming (with the DBA) what that
    idle timeout actually is and setting ``DB_POOL_RECYCLE_SECONDS``
    comfortably below it; turning it off with no matching recycle margin
    trades a quiet steady cost for occasional, noisier
    first-query-after-idle failures.

    Read once by :func:`database.connection.get_engine` /
    :func:`appdb.engine.build_engine` when the engine is constructed
    (like every other pool-shape setting there); changing it at runtime
    has no effect until the next :func:`~database.connection.dispose_engine`
    / :func:`~appdb.engine.dispose_app_engine`. Also consulted by
    :func:`api.health._ping_db` — see that function's docstring for why it
    changes how many round trips one health probe costs, not just whether
    checkout is probed."""
    db_pool_ping_idle_seconds: int = field(
        default_factory=lambda: int(os.getenv("DB_POOL_PING_IDLE_SECONDS", "60"))
    )
    """How long (in seconds) a pooled connection must have sat unused
    before :attr:`db_pool_pre_ping` (when on) actually probes it on
    checkout, rather than handing it straight to the caller. Default
    ``60``.

    This is the fix for the specific complaint that started the
    idle-aware-ping follow-up to Finding 1: with plain
    ``pool_pre_ping=True``, *every* checkout of a pooled connection sends
    ``SELECT 1`` first, even one microsecond after the previous caller
    checked the very same connection back in — a DBA sees this as a
    constant, checkout-rate-scaled stream of ``SELECT 1`` regardless of
    how few real questions are actually being asked. Tracking how long a
    connection has actually sat idle (via SQLAlchemy's documented
    connect/checkin/checkout pool events, see
    :func:`database.pool_ping.install_idle_aware_ping`) and skipping the
    probe when it has not been idle that long turns the cost back into
    "roughly one probe per burst of activity" instead of "one probe per
    query", while keeping exactly the same safety net for a connection
    that really has gone stale from sitting unused.

    ``0`` means "ping literally every checkout" — the old, simpler
    guarantee, for an operator who wants that back rather than the
    idle-aware trade-off, and its failure handling (invalidate the whole
    pool, not just one connection) now matches plain ``pool_pre_ping=True``
    exactly. It is not a byte-for-byte reproduction of old
    ``pool_pre_ping=True`` in one narrow respect: SQLAlchemy's own
    pre-ping never pings the very first checkout of a brand-new
    connection (a per-connection "fresh" flag, independent of idle time);
    ``0`` here pings even that one, since it honours "every checkout"
    literally. See :func:`database.pool_ping.install_idle_aware_ping`'s
    docstring for the full detail — the difference is observable only
    once per physical connection, on its first use, never again after.
    A negative value is not a supported input and is treated as ``0``
    would only coincidentally be handled the same way the idle comparison
    happens to read it (every checkout looks "idle enough"); use ``0``
    explicitly for that behaviour rather than relying on a negative
    number.

    Has no effect at all when :attr:`db_pool_pre_ping` is ``False`` — that
    setting means "never ping", and this one only ever narrows *when*
    that ping fires, never widens it back on. Read once when the engine is
    built, exactly like :attr:`db_pool_pre_ping` itself."""
    db_pool_recycle_seconds: int = field(
        default_factory=lambda: int(os.getenv("DB_POOL_RECYCLE_SECONDS", "3600"))
    )
    """Seconds after which :func:`database.connection.get_engine` recycles
    a pooled connection (SQLAlchemy's ``pool_recycle``), previously a bare
    ``3600`` literal in that function. See :attr:`db_pool_pre_ping` above
    for why lowering this (below whatever idle timeout the network path
    to the warehouse actually enforces) is the documented alternative to
    leaving ``pool_pre_ping`` on."""
    sql_dialect: str = field(
        default_factory=lambda: os.getenv("SQL_DIALECT", "tsql")
    )
    """The single SQL dialect this deployment targets, as a
    `sqlglot <https://sqlglot.com/>`_ dialect key -- one of ``"tsql"``
    (default), ``"postgres"``, ``"mysql"``, ``"sqlite"`` (see
    :data:`security.dialects.DIALECT_PROFILES`). Resolved once from config
    at start-up, the same way :class:`~llm.router.LLMRouter` resolves an
    endpoint -- this is portability (one dialect per deployment), not
    runtime routing across several live databases.

    SQL generation itself is unaffected by this setting: the model always
    generates ``tsql`` regardless (see ``<PROJECT_CONFIG_DIR>/system_prompt.md``
    and the multi-dialect phase report for why the static prompt prefix must
    stay byte-identical across every deployment). When this resolves to
    anything other than ``"tsql"``, ``llm.sql_agent.SQLAgent`` transpiles
    the guard-approved ``tsql`` SQL to this dialect with sqlglot and
    re-validates the **transpiled** text with
    :func:`~security.sql_guard.validate_sql` pinned to this dialect before
    executing it -- never executing anything the guard has not approved in
    the dialect it will actually run in. When this is ``"tsql"`` (the
    default), that transpile-and-revalidate step is skipped entirely and
    the guard-approved SQL is executed exactly as produced, unchanged from
    this deployment's original, single-dialect behaviour.

    Validated at start-up by :meth:`validate` via
    :func:`security.dialects.require_dialect_supported`, which fails
    closed for an unknown dialect or one with no system-catalogue
    blocklist configured -- see that function's docstring."""
    db_application_name: str = field(
        default_factory=lambda: os.getenv("DB_APPLICATION_NAME", "local-sql-agent")
    )
    """Client application name identified to the warehouse (Finding 5,
    2026 warehouse-load audit) — what a DBA sees as ``program_name`` in
    ``sys.dm_exec_sessions``/``sys.dm_exec_requests`` when attributing a
    trace or a blocking session to this application instead of to "some
    unlabelled connection". Only applied when :attr:`db_connection_url`
    is an ``mssql+pyodbc`` URL that does not already set one (see
    :func:`database.connection_identity.with_application_name`) — never
    overrides a value an operator already configured, and is a no-op for
    every other dialect (SQLite in tests, or a non-``pyodbc`` driver)."""
    query_timeout_seconds: int = field(
        default_factory=lambda: int(os.getenv("QUERY_TIMEOUT_SECONDS", "60"))
    )
    max_rows_returned: int = field(
        default_factory=lambda: int(os.getenv("MAX_ROWS_RETURNED", "1000"))
    )
    default_top_n: int = field(
        default_factory=lambda: int(
            os.getenv("DEFAULT_TOP_N", os.getenv("MAX_ROWS_RETURNED", "1000"))
        )
    )
    """Row cap injected by :func:`security.sql_guard.ensure_top` into
    generated SQL that has no ``TOP``/row-limit clause of its own.
    Defaults to whatever ``MAX_ROWS_RETURNED`` resolves to (the same
    number the client-side ``fetchmany`` cap already uses), so the two
    caps agree unless ``DEFAULT_TOP_N`` is set independently."""
    log_dir: str = field(
        default_factory=lambda: os.getenv("LOG_DIR", "logs")
    )
    export_dir: str = field(
        default_factory=lambda: os.getenv("EXPORT_DIR", "exports")
    )
    project_config_dir: str = field(
        default_factory=lambda: os.getenv("PROJECT_CONFIG_DIR", "project_config")
    )
    """Directory :mod:`knowledge.config_loader` and :mod:`schema_data.registry`
    read their YAML files from (``aliases.yaml``, ``entities.yaml``,
    ``business_rules.yaml``, ``examples.yaml``, ``metrics.yaml``,
    ``schema.yaml``). Defaults to ``project_config`` — the git-ignored
    directory holding this deployment's real domain data and warehouse
    schema. Point this at ``project_config.example`` to run against the
    committed, sample-data template instead (a fresh clone with no
    ``project_config/`` at all, or CI).

    Deliberately **not** an automatic fallback: when this resolves to
    ``project_config`` (the default) and that directory or one of its
    files is missing, loading still raises ``ConfigNotFoundError`` exactly
    as before — silently running on sample aliases/business rules against
    a real warehouse would produce confidently wrong SQL, which is worse
    than refusing to start. The example directory is only ever reached by
    explicitly setting this variable."""
    staging_patterns: str = field(
        default_factory=lambda: os.getenv(
            "STAGING_PATTERNS",
            "*_Stg,*stg*,*staging*,table:*_Inserts,table:*_Updates,table:*_Deletes",
        )
    )
    """Which ``schema.yaml`` tables ``scripts/verify_deployment.py``'s
    ``No staging or change-capture tables`` check reports as probably not
    meant for analysts. Comma-separated ``fnmatch`` patterns, matched
    case-insensitively. A bare pattern is matched against the table's
    schema name (``*_Stg`` matches ``Sales_Stg``); a ``table:`` prefix
    matches the table's own name instead (``table:*_Inserts`` matches
    ``Invoice_Inserts``, whatever its schema). The default covers staging
    schemas and the ``_Inserts`` / ``_Updates`` / ``_Deletes`` tables a
    change-capture process writes. An empty value turns the check off. The
    check only advises (it never fails), and reads ``schema.yaml``, not the
    database."""
    exclude_schemas: str = field(
        default_factory=lambda: os.getenv("EXCLUDE_SCHEMAS", "")
    )
    """Database schemas the setup tools never scan, for every data source:
    comma-separated ``fnmatch`` patterns, matched case-insensitively against
    the schema name (``*_Stg`` matches ``Sales_Stg`` and ``sales_stg``). Added
    to the always-excluded system schemas
    (:data:`database.schema_filter.SYSTEM_SCHEMAS`) and to a data source's own
    ``exclude_schemas:`` list in ``datasources.yaml``, so a deployment with a
    single ``DB_CONNECTION_URL`` has the same control as one with several
    sources. It decides which catalogue tables the inspector drafts, the
    ``sync_schema.py`` report lists and ``--add-tables`` adds; a table
    ``schema.yaml`` already lists is synced as before, and the preflight check
    ``No table in an excluded schema`` reports one that sits in such a schema.
    Empty (the default) excludes nothing beyond the system schemas."""
    exclude_tables: str = field(
        default_factory=lambda: os.getenv("EXCLUDE_TABLES", "")
    )
    """Database tables the setup tools never scan, for every data source:
    comma-separated ``fnmatch`` patterns, matched case-insensitively against
    ``schema.table`` (``*.Order`` matches ``sales.Order`` and ``ref.order``;
    ``X_Stg.*_Delete`` matches ``x_stg.Trade_Delete``). A pattern with no ``.``
    is matched against the table name alone, in any schema. Added to a data
    source's own ``exclude_tables:`` list in ``datasources.yaml``, so a
    deployment with a single ``DB_CONNECTION_URL`` has the same control as one
    with several sources. Like ``EXCLUDE_SCHEMAS`` it decides which catalogue
    tables the inspector drafts, the ``sync_schema.py`` report lists and
    ``--add-tables`` adds; a table ``schema.yaml`` already lists is synced as
    before, and the preflight check ``No table in an excluded schema`` reports
    one that matches. Empty (the default) excludes nothing."""
    # ── query result cache ────────────────────────────────────────────────
    cache_ttl_seconds: int = field(
        default_factory=lambda: int(os.getenv("CACHE_TTL_SECONDS", "300"))
    )
    """How long (seconds) a cached query result stays valid.  0 = disabled."""

    cache_max_size: int = field(
        default_factory=lambda: int(os.getenv("CACHE_MAX_SIZE", "256"))
    )
    """Maximum number of distinct (question, mode) pairs to keep in memory."""

    # ── /health probe cache (Finding 3, 2026 audit) ─────────────────────────
    health_cache_ttl_seconds: int = field(
        default_factory=lambda: int(os.getenv("HEALTH_CACHE_TTL_SECONDS", "15"))
    )
    """How long (seconds) ``api/health.py::check_health()`` reuses a probe
    result before running the (real, ~5s-timeout, connection-pool-using)
    LLM-endpoint and database checks again.

    ``/health`` needs no credentials and ``api/middleware.py`` deliberately
    keeps it exempt from rate limiting -- a liveness probe must never be
    throttled away, or an orchestrator kills a healthy container -- which
    before this field existed meant an unauthenticated caller could hold
    open a connection from the same pool ``/query`` uses, with no limit on
    how often. Caching (not rate-limiting) is the fix: see
    ``api/health.py``'s own "Result caching" section for the incident that
    motivated it and the reasoning for why caching is the right lever here.

    Long enough to collapse a burst or a tightly-polling load balancer
    into a single real probe; short enough that a genuine outage (or
    recovery) still surfaces well within a liveness-check interval.
    ``api/health.py`` re-asserts ``0 < value <= 30`` as a documented
    invariant of the deployment default, not enforced here -- see that
    module's own test coverage. Exposed as ``api.health.HEALTH_CACHE_TTL_SECONDS``
    via that module's ``__getattr__`` (a live read of this field on every
    access, never a value captured once at import time) so existing callers
    reading it as a module-level constant keep working unchanged."""

    # ── Admin panel: expensive-card result cache (Finding 2, 2026 audit) ────
    admin_expensive_cache_ttl_seconds: int = field(
        default_factory=lambda: int(os.getenv("ADMIN_EXPENSIVE_CACHE_TTL_SECONDS", "300"))
    )
    """How long (seconds) the admin panel's warehouse-touching cards --
    currently ``GET /admin/health/checks`` (every
    :mod:`scripts.verify_deployment` check, run live) and
    ``GET /admin/schema-drift`` (a full catalogue reflection of every
    table/column in every schema) -- reuse their last result before
    running the underlying checks again. See :mod:`api.admin_result_cache`'s
    module docstring for the incident this fixes: ``web/admin/main.js``'s
    30-second auto-refresh used to re-run both of these on every tick with
    an admin tab merely left open, and the deployment-checks card also ran
    a rolled-back ``CREATE TABLE`` DDL attempt and a ``WAITFOR DELAY``
    probe on every one of those ticks (see ``api.admin_routes.admin_health_checks``'s
    ``deep`` parameter — gating those two checks behind an explicit
    opt-in is a separate fix, not this TTL).

    ``0`` disables caching entirely (every request runs the check live),
    matching :attr:`cache_ttl_seconds`'s own "0 = disabled" convention.
    Each card's own ``?refresh=1`` query parameter bypasses a still-valid
    cached value for that one request without changing this setting or
    affecting any other card's cache. A failed check (e.g. the warehouse
    is genuinely unreachable) is never itself cached — see
    :mod:`api.admin_result_cache`'s "a failed computation is never
    cached" note — so a real outage is never masked behind a stale
    "everything passed" result for the length of this TTL.

    300s (5 minutes) is long enough that leaving the panel open all day
    costs a handful of runs rather than thousands, and short enough that
    an operator who deployed a fix and reopens the panel a few minutes
    later sees it reflected without needing to know to press refresh."""

    # ── JSONL log rotation ──────────────────────────────────────────────────
    log_max_bytes: int = field(
        default_factory=lambda: int(os.getenv("LOG_MAX_BYTES", str(50 * 1024 * 1024)))
    )
    """Size cap (bytes) for a JSONL log file (``query_log.jsonl``,
    ``audit_log.jsonl``) before it is rotated. ``<= 0`` disables rotation
    (the file grows without bound). Read at call time by
    ``logs.logger._rotation_settings()``, per this project's
    read-through-``cfg.settings``-at-call-time convention -- this field was
    previously read directly via ``os.getenv`` in ``logs/logger.py``
    because ``config.py`` was locked by concurrent work when that module
    was written; it is free now, so the field lives here like every other
    setting.

    Raised from ``10 MiB`` alongside :attr:`log_backup_count` below —
    together they bound how much audit history a rotation can ever discard
    (see that field's docstring for the arithmetic and the incident that
    prompted it). Raise this further on a deployment with materially higher
    traffic than the ~1 KB/record this project has actually observed;
    lower it only if disk space is the tighter constraint than audit
    history, which for a compliance/analysis log is rarely the right
    trade."""

    log_backup_count: int = field(
        default_factory=lambda: int(os.getenv("LOG_BACKUP_COUNT", "20"))
    )
    """Number of rotated log backups to retain. ``<= 0`` keeps no history
    (the file is cleared in place instead of shifted to ``.1`` on
    rotation). See :attr:`log_max_bytes` for why this lives here rather
    than behind a direct ``os.getenv`` call.

    Raised from ``5`` together with :attr:`log_max_bytes`'s five-fold
    increase (``10 MiB -> 50 MiB``): a first production deployment's
    ``audit_log.jsonl`` is the only source this project has ever had for
    real accuracy/latency numbers, and losing its earliest records to a
    rotation nobody was watching would be silent and unrecoverable —
    there is no second chance at "the first week". Observed audit records
    average roughly 1 KB each (per-stage timings, the full ``llm`` status
    block, guard verdict); the old ``10 MiB x 5`` = 50 MiB ceiling (about
    50,000 records total) could plausibly be exhausted within the first
    production week by traffic well short of anything unusual, especially
    stacked on top of whatever pre-deployment dev/test traffic already
    shares the same file. The new ``50 MiB x 20`` = 1 GiB ceiling (on the
    order of a million records) is not "unbounded" — a genuinely runaway
    write loop still eventually rotates its oldest history away rather
    than filling the disk forever — but comfortably outlasts any real
    single-organisation deployment's first weeks. Reassess this number
    once real production volume is known; do not simply raise it further
    "to be safe" without knowing the actual record rate, since that trades
    away the runaway-growth backstop for no measured benefit."""

    # ── Phase 2: static prompt prefix / prefix-cache latency win ────────────
    prompt_retrieval_token_budget: int = field(
        default_factory=lambda: int(os.getenv("PROMPT_RETRIEVAL_TOKEN_BUDGET", "6000"))
    )
    """Token-estimate threshold (see ``prompt_engine.static_prefix.estimate_tokens``)
    above which :class:`~prompt_engine.builder.PromptBuilder` falls back to
    per-question retrieval instead of the static, byte-identical prefix. The
    knowledge base measured at Phase 2 kickoff (system prompt + full schema +
    relationships + business rules + examples) is ~4.6k real tokens, comfortably
    under this default — today's 12-table schema always takes the static path.
    A larger schema added in a later phase can exceed the budget and
    transparently falls back to the six-retriever pipeline (still exercised,
    never dead code) instead of blowing up the prompt or losing accuracy."""

    # ── Table retrieval: recall knobs (docs/design/RETRIEVAL.md) ────────────
    retrieval_extra_tables: int = field(
        default_factory=lambda: int(os.getenv("RETRIEVAL_EXTRA_TABLES", "3"))
    )
    """How many tables ranked by description and column evidence may be added
    to the ones a configured alias or fact pattern already named
    (``retrieval.entity_retriever`` / ``retrieval.fact_retriever``). An alias
    hit used to end the search for that kind of table, so a question naming
    one aliased dimension and one that has no alias lost the second. ``0``
    restores that behaviour."""

    retrieval_extra_score_ratio: float = field(
        default_factory=lambda: float(os.getenv("RETRIEVAL_EXTRA_SCORE_RATIO", "0.5"))
    )
    """An extra table (see :attr:`retrieval_extra_tables`) must score at least
    this fraction of the best table of its kind. Higher is stricter: fewer
    extras, higher precision."""

    retrieval_join_expansion: bool = field(
        default_factory=lambda: os.getenv("RETRIEVAL_JOIN_EXPANSION", "true").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, the tables a question needs to be joined to each other
    but did not name (a bridge table, the dimension a classification hangs
    from) are added to the retrieved set, found on the foreign-key graph
    (``schema.yaml`` relationships, ``relationships.yaml`` and, see
    :attr:`retrieval_infer_relationships`, the ``<Table>_ID`` naming
    convention). See ``retrieval.join_paths``."""

    retrieval_join_max_hops: int = field(
        default_factory=lambda: int(os.getenv("RETRIEVAL_JOIN_MAX_HOPS", "2"))
    )
    """Longest join (in foreign keys) :attr:`retrieval_join_expansion` will
    bridge between two retrieved tables; ``2`` allows one intermediate table
    (fact - bridge - dimension). A pair further apart is left unconnected
    rather than pulling in a chain. On the synthetic benchmark ``3`` and ``4``
    found nothing more and cost 0.26 and 0.53 more tables per question
    (docs/design/RETRIEVAL.md); raise it if your schema chains bridges."""

    retrieval_join_max_added_tables: int = field(
        default_factory=lambda: int(os.getenv("RETRIEVAL_JOIN_MAX_ADDED_TABLES", "4"))
    )
    """Most tables :attr:`retrieval_join_expansion` adds to one question's
    retrieved set. A path that would exceed it, or push the schema block past
    :attr:`prompt_retrieval_token_budget`, is skipped."""

    retrieval_join_max_hub_degree: int = field(
        default_factory=lambda: int(os.getenv("RETRIEVAL_JOIN_MAX_HUB_DEGREE", "10"))
    )
    """A table that more than this many tables reference (a calendar or
    currency dimension shared by every fact) is never used as a stepping
    stone between two other tables; it still joins when the question names it."""

    retrieval_infer_relationships: bool = field(
        default_factory=lambda: os.getenv("RETRIEVAL_INFER_RELATIONSHIPS", "true").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, a column named ``<Table>_ID`` / ``<Table>ID`` is taken as a
    foreign key to that table's ``ID`` for join-path expansion when neither
    ``schema.yaml`` nor ``relationships.yaml`` declares the edge. Used for
    retrieval only; nothing is written to configuration and no join hint is
    added to the prompt."""

    retrieval_prune: bool = field(
        default_factory=lambda: os.getenv("RETRIEVAL_PRUNE", "true").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, the candidates of the entity and fact retrievers go
    through ``retrieval.pruning`` before join-path expansion: a table that only
    its description matched, or a column match well below the best one, is
    dropped unless it is joined to a better-evidenced table. Recall of the
    candidate generation is bought with extra tables; this takes the ones the
    evidence does not support back out."""

    retrieval_prune_score_ratio: float = field(
        default_factory=lambda: float(os.getenv("RETRIEVAL_PRUNE_SCORE_RATIO", "0.85"))
    )
    """A table with column-name evidence must score at least this fraction of
    the best unforced score to count as an anchor on its own; below it, it
    survives only by being joined to an anchor. ``0`` keeps every such table."""

    retrieval_prune_connect_hops: int = field(
        default_factory=lambda: int(os.getenv("RETRIEVAL_PRUNE_CONNECT_HOPS", "2"))
    )
    """How many foreign keys may separate a weakly evidenced table from an
    anchor for it to count as joined (``2`` reaches a table through a bridge).
    ``0`` disables the rescue."""

    retrieval_prune_corroborate: bool = field(
        default_factory=lambda: os.getenv("RETRIEVAL_PRUNE_CORROBORATE", "true").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, two candidates that no anchor supports but that are joined
    to each other (within :attr:`retrieval_prune_connect_hops`) keep each other:
    each was matched on its own and the join graph agrees. It saves the tables of
    a question whose strongest alias hit is a false one (an alias that is a
    substring of the word the question actually uses)."""

    # ── Phase 2: deterministic decoding (docs/api-contract-v2.md §6) ───────
    llm_temperature: float = field(
        default_factory=lambda: float(os.getenv("LLM_TEMPERATURE", "0.0"))
    )
    """Sampling temperature sent to the backend. ``0.0`` (the default) makes
    decoding deterministic given a fixed seed — required so that the same
    question produces byte-identical SQL on every run."""

    llm_top_p: float = field(
        default_factory=lambda: float(os.getenv("LLM_TOP_P", "1.0"))
    )
    """Nucleus-sampling cutoff. ``1.0`` disables nucleus sampling, which
    matters for determinism only when combined with ``llm_temperature=0.0``
    and a fixed ``llm_seed``."""

    llm_seed: int = field(
        default_factory=lambda: int(os.getenv("LLM_SEED", "7"))
    )
    """Fixed sampling seed, sent on every request. Honoured by vLLM and
    llama.cpp; accepted but not guaranteed-deterministic by OpenAI's own
    hosted API (see its ``system_fingerprint`` field) — this project sends
    it regardless (never harmful) and reports whether the endpoint *claims*
    to honour it in the ``llm`` status block's ``seed_honored`` rather than
    asserting determinism this process cannot verify on its own. Combined
    with ``temperature=0`` and a genuinely deterministic endpoint, this is
    what makes ``the same question, run twice, produce byte-identical SQL``
    (a Phase 2 exit criterion) rather than merely "usually similar"."""

    llm_num_predict: int = field(
        default_factory=lambda: int(os.getenv("LLM_NUM_PREDICT", "512"))
    )
    """Bounded max completion length (sent as ``max_tokens``). A generated
    SQL statement is a few dozen tokens at most; capping this bounds worst-case
    decode latency and protects against a runaway generation looping forever."""

    llm_extra_body_json: str = field(
        default_factory=lambda: os.getenv("LLM_EXTRA_BODY", "")
    )
    """Extra JSON fields merged into every chat-completions request body, as
    a JSON object. Empty (the default) sends exactly what this project has
    always sent.

    This exists because "turn the model's reasoning off" has no place in
    the OpenAI chat-completions schema, and every server spells it
    differently: vLLM and SGLang take
    ``{"chat_template_kwargs": {"enable_thinking": false}}`` for Qwen3,
    Ollama takes ``{"think": false}``, and others use ``reasoning_effort``.
    Encoding those dialects here would mean this project claiming to know
    every inference server's private vocabulary, and silently sending the
    wrong key whenever it guessed wrong. A passthrough says the honest
    thing instead: these are your server's fields, sent verbatim.

    It matters more than a tuning knob suggests. A reasoning model spends
    its completion budget thinking before it answers, so with
    :attr:`llm_num_predict` at its default a Qwen3-class model can consume
    every token on reasoning and be cut off (``finish_reason: "length"``)
    before emitting a single character of SQL — which arrives as
    ``EMPTY_SQL_RESPONSE``, a message about the response rather than about
    the cause.

    Keys that would let this field change *which model is asked what* are
    rejected at parse time rather than merged — see
    :data:`llm.providers.RESERVED_PAYLOAD_KEYS`. Everything else is the
    operator's business, not this module's."""

    llm_stop: tuple[str, ...] = field(default_factory=tuple)
    """Optional stop sequences appended to every request's ``stop`` field.
    Empty by default — most models terminate cleanly at the SQL statement's
    natural end; set this only if a specific model needs an explicit fence."""

    # ── Phase 2: query-result cache normalisation ───────────────────────────
    cache_normalize_questions: bool = field(
        default_factory=lambda: os.getenv("CACHE_NORMALIZE_QUESTIONS", "true").lower()
        not in ("0", "false", "no")
    )
    """When true (default), cache keys are built from a normalised form of
    the question (collapsed whitespace, Persian/Arabic digits folded to
    ASCII, ZWNJ stripped, ي/ك folded to ی/ک) so trivially different
    phrasings of the same question share a cache entry. See
    ``api.query_cache._normalize_question``."""

    # ── Phase 2: LLM router / endpoint-trust governance ─────────────────────
    llm_provider: str = field(
        default_factory=lambda: os.getenv("LLM_PROVIDER", "openai")
    )
    """One of ``"openai"`` (the default — route through :mod:`llm.endpoints`)
    or ``"mock"`` (every task answered by :class:`~llm.providers.MockBackend`,
    no endpoint configuration needed — for tests and offline runs). See
    ``llm.router.LLMRouter.from_settings``."""

    llm_allow_remote: bool = field(
        default_factory=lambda: os.getenv("LLM_ALLOW_REMOTE", "false").lower()
        in ("1", "true", "yes")
    )
    """Explicit per-deployment opt-in gate for sending schema, business
    rules, or query-result rows to an untrusted (not on this deployment's
    own infrastructure) LLM endpoint. This product's premise is "runs on
    your infrastructure" — pointing ``OPENAI_BASE_URL``/``LLM_ENDPOINTS``
    at a hosted API alone must never be sufficient to start exfiltrating
    data; this flag is the separate, deliberate switch, and every call to
    an untrusted endpoint made while it is true is written to the audit
    trail (see ``llm.router.LLMRouter``). Defaults to ``False``."""

    llm_trusted: bool | None = field(
        default_factory=lambda: (
            {"true": True, "1": True, "yes": True, "false": False, "0": False, "no": False}
            .get(os.getenv("LLM_TRUSTED", "").strip().lower())
        )
    )
    """Explicit trust override for the single ``"default"`` endpoint (see
    :attr:`openai_base_url`). ``None`` (unset — the default) defers to
    :func:`~llm.trust.default_trust_for_url`, which trusts loopback/private/
    ``.local`` addresses and nothing else. Set to ``true``/``false`` to
    override that heuristic explicitly — e.g. a local endpoint reachable
    under a public-looking hostname that should still count as trusted, or
    a loopback endpoint that should not. Endpoints declared in
    :attr:`llm_endpoints_json` each carry their own independent ``trusted``
    override instead of sharing this one."""

    llm_endpoints_json: str = field(
        default_factory=lambda: os.getenv("LLM_ENDPOINTS", "")
    )
    """Additional named endpoints beyond the trivial ``"default"`` one, as
    a JSON array of ``{"name", "base_url", "model", "api_key", "trusted"}``
    objects (``api_key``/``trusted`` optional). Empty (the default) means
    only ``"default"`` exists. See :mod:`llm.endpoints`."""

    llm_routes_json: str = field(
        default_factory=lambda: os.getenv("LLM_ROUTES", "")
    )
    """Per-task endpoint fallback chains, as a JSON object mapping a
    :class:`~llm.router.TaskType` value (``"sql_generation"``,
    ``"interpretation"``, ``"assumption_extraction"``) to a list of
    endpoint names tried in order. Empty (the default) routes every task to
    ``["default"]`` alone. See :mod:`llm.endpoints`."""

    llm_task_budget_seconds: float | None = field(
        default_factory=lambda: (
            float(os.getenv("LLM_TASK_BUDGET_SECONDS", ""))
            if os.getenv("LLM_TASK_BUDGET_SECONDS", "").strip()
            else None
        )
    )
    """Per-task latency budget (seconds) applied uniformly to every
    :class:`~llm.router.TaskType` by :meth:`~llm.router.LLMRouter.from_settings`.
    ``None`` (default, unset) disables the budget check entirely — see
    ``llm.router.LLMRouter._call_chain``."""

    # ── Latency: prefix-cache warm-up and the split llm stage ───────────────
    llm_prefix_warmup_on_startup: bool = field(
        default_factory=lambda: os.getenv(
            "LLM_PREFIX_WARMUP_ON_STARTUP", "true",
        ).lower() in ("1", "true", "yes")
    )
    """When ``True``, ``api/server.py``'s ``lifespan`` starts a background
    thread at startup that sends the model server one minimal request per
    data source whose prompt uses the static prefix
    (:func:`~prompt_engine.static_prefix.should_use_static_prefix`): the
    messages are exactly the cacheable prefix real requests start with and
    ``max_tokens`` is ``1``. The server computes and keeps that prefix's
    KV cache, so the first real question after a restart does not pay the
    full prefill (about a minute on a large local model) -- see
    :mod:`llm.warmup`.

    Defaults to ``True``. It is safe as a default because it does nothing
    unless there is something to warm: a deployment whose schema is over
    ``PROMPT_RETRIEVAL_TOKEN_BUDGET`` (the retrieval path, no stable
    prefix), ``LLM_PROVIDER=mock``, and an endpoint that is not trusted
    (``LLM_ALLOW_REMOTE`` is the same gate real requests pass) all skip it.
    It never blocks startup (the server accepts requests at once; a
    question that arrives mid-warm-up simply runs as it would have),
    and every failure is logged and swallowed. It only helps if the
    model server caches prefixes (``vllm serve --enable-prefix-caching``,
    on by default in recent vLLM, or llama.cpp's prompt cache); on a
    server that does not, it costs one extra prefill at startup and
    nothing more. Set to ``false`` for a deployment whose model server is
    shared and billed per token, or whose startup must send no LLM
    traffic. ``POST /admin/llm/warmup`` runs the same warm-up on demand,
    whatever this is set to."""

    llm_prefix_warmup_timeout_seconds: float = field(
        default_factory=lambda: float(os.getenv("LLM_PREFIX_WARMUP_TIMEOUT_SECONDS", "180"))
    )
    """Total time budget (seconds) for one warm-up pass, across all data
    sources. Each source's request gets whatever is left of the budget as
    its HTTP timeout, and a source reached after the budget is spent is
    skipped (logged, not retried). The request is never retried: a warm-up
    that fails is a warm-up the next real question replaces. Must be
    greater than zero. The default is three times the ~1 minute first-
    question prefill that motivated the feature."""

    llm_prefix_keepwarm_seconds: float = field(
        default_factory=lambda: float(os.getenv("LLM_PREFIX_KEEPWARM_SECONDS", "").strip() or "0")
    )
    """Seconds between keep-warm passes; ``0`` (the default) turns it off.

    The start-up warm-up (:attr:`llm_prefix_warmup_on_startup`) primes the
    model server's prefix cache once. On a model server shared with other
    users that cache is evicted again whenever their traffic needs the
    memory, and the next question pays the full prefill of the prefix --
    about a minute on a large local model. With a value above zero a
    background thread re-sends each static-prefix source's prefix (the
    request the warm-up sends: the prefix alone, ``max_tokens=1``) every
    that many seconds, so the prefix never sits unused for long enough to
    be the oldest thing in the cache.

    It costs nothing under traffic: a source whose prefix a real request
    used within the last interval is skipped, because that request already
    refreshed it. Idle, it costs one cached-prefix lookup and one generated
    token per source per pass. It obeys every rule the start-up warm-up
    does: nothing for a source on the retrieval path, nothing under
    ``LLM_PROVIDER=mock``, nothing to an endpoint that is not trusted unless
    ``LLM_ALLOW_REMOTE`` is on; every failure is logged once per streak and
    never fatal. It pauses while maintenance mode is on and stops at
    shutdown. Its last run is on ``GET /admin/llm/warmup``.

    Must be ``0`` or at least 30: a shorter interval is a load generator,
    not a keep-warm. Runbook section 20.7 recommends a value."""

    llm_cache_suspect_ms: int = field(
        default_factory=lambda: int(os.getenv("LLM_CACHE_SUSPECT_MS", "").strip() or "50")
    )
    """Reply time, in milliseconds, under which a repeated SQL text is taken as
    a sign of a response cache in front of the model; ``0`` switches the early
    warning off.

    A question whose generated SQL is byte-identical to the SQL of a
    *different* question seen shortly before, and whose model reply took less
    than this, makes the server log one ``WARNING`` per streak and write
    ``suspected_cached_response: true`` in the audit record's ``llm`` block
    (``observability/response_cache_watch.py``). It never blocks or changes a
    request: two questions can legitimately map to the same SQL. Generating a
    statement takes longer than 50 ms on any real model server, so a reply
    that fast is not a generation. Runbook section 20.10."""

    llm_stream_timings: bool = field(
        default_factory=lambda: os.getenv("LLM_STREAM_TIMINGS", "false").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, ``llm.providers.OpenAIBackend`` sends each
    chat-completions request with ``stream=true`` (and
    ``stream_options={"include_usage": true}``) and reassembles the
    streamed chunks into the same response it would have received without
    streaming -- same ``content``, reasoning text, ``finish_reason`` and
    ``usage`` -- while timing the first token. The audit ``llm`` block then
    carries ``ttft_ms`` (queue plus prefill) and ``generation_ms`` (first
    token to last) alongside ``total_ms``, which is the only way to tell a
    cold prefix cache or a busy server from a long generation, and
    reasoning from answer.

    Defaults to ``False``: the non-streaming request is what this project
    has always sent, and the streamed one differs in one way a deployment
    may notice -- the request timeout then bounds the gap between chunks
    as well as the whole call (see
    :meth:`llm.providers.OpenAIBackend.generate_with_meta`), and some
    proxies buffer or drop server-sent events. Turn it on to measure,
    compare ``total_ms`` with it off, and leave it on if nothing changes
    (it should not). Applies to free-text generation; the constrained
    (``LLM_STRUCTURED_OUTPUT``) request is not streamed."""

    # ── Phase 3: conversational sessions (docs/api-contract-v2.md §9) ──────
    session_ttl_seconds: int = field(
        default_factory=lambda: int(os.getenv("SESSION_TTL_SECONDS", "1800"))
    )
    """Idle-expiry window (seconds) for an in-memory conversational session
    (``session.store.SessionStore``). A session not touched (no new turn,
    no transcript read) for this long is evicted, freeing its cached
    turns/memory. ``0`` disables sessions entirely (every lookup misses)."""

    session_max_count: int = field(
        default_factory=lambda: int(os.getenv("SESSION_MAX_COUNT", "500"))
    )
    """Maximum number of concurrent sessions kept in memory. Beyond this,
    the least-recently-active session is evicted to make room for a new
    one — the same LRU discipline ``api.query_cache.QueryCache`` already
    applies to cached responses."""

    session_max_turns: int = field(
        default_factory=lambda: int(os.getenv("SESSION_MAX_TURNS", "50"))
    )
    """Transcript cap per session. Beyond this many turns, the oldest turn
    (and its session-memory sidecar) is dropped from the session so a
    long-lived conversation cannot grow without bound."""

    session_prompt_turns: int = field(
        default_factory=lambda: int(os.getenv("SESSION_PROMPT_TURNS", "3"))
    )
    """How many of the most recent turns are rendered into the prompt's
    session-context block (question, SQL, result *column names* — never
    row data; see ``docs/api-contract-v2.md`` §8). Older turns fall out of
    the prompt window but remain readable via ``GET /v2/sessions/{sid}``."""

    refinement_scan_cap: int = field(
        default_factory=lambda: int(
            os.getenv(
                "REFINEMENT_SCAN_CAP",
                str(int(os.getenv("MAX_ROWS_RETURNED", "1000")) * 100),
            )
        )
    )
    """§2's inner-scan bound: when a refining turn drops the previous
    turn's display ``TOP`` to compute a result over *all* matching rows
    (not just the ones previously shown), the resulting unbounded scan is
    re-capped at this many rows instead of being left unbounded. Defaults
    to ``max_rows_returned * 100``, mirroring ``default_top_n``'s own
    fallback-to-``MAX_ROWS_RETURNED`` pattern above."""

    # ── Phase 9: session persistence + cross-session memory ─────────────────
    session_store_path: str = field(
        default_factory=lambda: os.getenv("SESSION_STORE_PATH", "logs/sessions.db")
    )
    """SQLite file backing :class:`session.persistence.SessionPersistence`
    (question, generated SQL, result column names/row_count/truncated, the
    ``TurnMemory`` sidecar, and memory entries — never result rows; see that
    module's docstring). Defaults to ``logs/sessions.db`` — ``*.db`` is
    already gitignored, and ``logs/`` is where the audit log already lives,
    so this introduces no new category of stored data. Empty string
    disables persistence entirely: ``session.store.SessionStore`` then
    behaves exactly as it did before this phase (TTL expiry deletes, no
    rehydration, ``GET /v2/sessions`` reflects only the in-memory hot set).
    Read once, at ``api.v2_routes.get_session_store``'s lazy-construction
    time, like every other singleton-constructor setting in this codebase
    (``session_ttl_seconds``, ``session_max_count``, ...)."""

    session_retention_days: int = field(
        default_factory=lambda: int(os.getenv("SESSION_RETENTION_DAYS", "30"))
    )
    """How long (days) a persisted conversation stays listable and
    reopenable — the third, previously-conflated lifetime alongside
    :attr:`session_prompt_turns` (the prompt window) and
    :attr:`session_ttl_seconds` (how long the in-memory record stays hot).
    ``session.store.SessionStore.purge_expired`` permanently deletes a
    persisted session past this many days since its ``last_active_at``;
    it does not affect the (much shorter) in-memory TTL, which only
    demotes a session out of memory, never deletes it, when persistence is
    attached."""

    session_title_max_length: int = field(
        default_factory=lambda: int(os.getenv("SESSION_TITLE_MAX_LENGTH", "80"))
    )
    """Cap on a session title's length — both the auto-derived title (the
    first question, truncated at a word boundary) and a title supplied via
    ``PATCH /v2/sessions/{sid}``. A title is presentation only: it is
    validated under the same length/control-character rules as a memory
    value (see :attr:`memory_value_max_length`) precisely because it is
    user text of the same shape, but it never enters a prompt."""

    memory_enabled: bool = field(
        default_factory=lambda: os.getenv("MEMORY_ENABLED", "true").lower()
        not in ("0", "false", "no")
    )
    """Master switch for cross-session memory (``docs/api-contract-v2.md``
    §5). When ``False``, a stored entry is never applied to a turn's
    assumptions/filters (``session.engine.TurnEngine.ask`` skips
    :func:`session.memory.apply_memory_to_assumptions` entirely) — the
    ``GET``/``PUT``/``DELETE /v2/memory*`` endpoints themselves are
    unaffected, so an operator can disable the feature's *effect* without
    losing an analyst's already-pinned entries."""

    memory_max_entries_per_principal: int = field(
        default_factory=lambda: int(os.getenv("MEMORY_MAX_ENTRIES_PER_PRINCIPAL", "20"))
    )
    """Cap on how many distinct memory keys one principal may have pinned
    at once. Exceeding it on ``PUT /v2/memory/{key}`` (a *new* key; updating
    an already-pinned key never counts against this cap) is an explicit
    422, never a silent eviction of an older entry — see
    ``docs/api-contract-v2.md`` §5."""

    memory_value_max_length: int = field(
        default_factory=lambda: int(os.getenv("MEMORY_VALUE_MAX_LENGTH", "120"))
    )
    """Default per-key cap on a memory value's length, in
    ``project_config/memory_policy.yaml``'s absence of a narrower
    ``max_length`` for that specific key. A memory value is untrusted text
    that reaches the prompt's variable suffix (never the static prefix),
    so it is also rejected outright for a newline or control character —
    see ``session.memory.validate_memory_value``."""

    cors_allowed_origins: tuple[str, ...] = field(
        default_factory=lambda: tuple(
            o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
        )
        or DEFAULT_CORS_ALLOWED_ORIGINS
    )
    """Origins allowed to call this API cross-origin (``CORSMiddleware`` in
    ``api/server.py``), comma-separated.

    Defaults to :data:`DEFAULT_CORS_ALLOWED_ORIGINS` — the loopback
    origins the bundled ``web/`` UI is documented to be served from.
    Setting the variable replaces that list entirely rather than adding
    to it, so a real deployment naming its own origin does not silently
    keep localhost allowed as well.

    Same-origin requests never need CORS at all, so this only matters for
    the split-origin layout ``web/README.md`` and
    ``docs/fa/getting-started.md`` describe: the API on one port, the
    static UI on another."""

    llm_structured_output: bool = field(
        default_factory=lambda: os.getenv("LLM_STRUCTURED_OUTPUT", "false").lower()
        in ("1", "true", "yes")
    )
    """Phase 2 task 3 feature flag: when ``True``, SQL generation asks for
    a single constrained JSON object (``{"sql", "out_of_scope",
    "confidence", "assumptions"}``, see ``llm.structured_schema``) instead
    of free text plus ``clean_sql`` regex surgery. Defaults to ``False``
    until the golden-set evaluation (``python -m eval.cli run --live
    --structured``) demonstrates it does not regress accuracy — see the
    Phase 2 report for whether that evaluation has been run yet."""

    # ── Deployment readiness: per-(principal, ip) rate limiting ─────────────
    # Previously ``api/middleware.py``'s own module-level ``os.getenv()``
    # reads, evaluated once at that module's import time -- which is why
    # ``tests/conftest.py`` had to set these env vars *before* anything
    # imported ``api.middleware`` at all (see that file's own comment).
    # Moved here so the values are read through ``cfg.settings`` at call
    # time like every other tuning knob (see this module's "Three layers,
    # not two" section) -- ``RateLimitMiddleware.__init__`` now resolves
    # them itself when it is actually constructed, so
    # ``config.override_settings(rate_limit_requests=...)`` reaches a
    # middleware instance built with no explicit constructor kwargs, not
    # just one a test hand-configures. See ``api/middleware.py``'s module
    # docstring for why the *shared* ``api.server.app`` instance still
    # needs its generous test-suite value in place before the first
    # request of a whole pytest session, not merely "at some point before
    # a given test" -- Starlette builds and caches that app's middleware
    # stack exactly once, on the first request it ever serves.
    rate_limit_requests: int = field(
        default_factory=lambda: int(os.getenv("RATE_LIMIT_REQUESTS", "600"))
    )
    """Sustained request allowance per :attr:`rate_limit_window_seconds`,
    per rate-limit bucket (see :attr:`rate_limit_burst` for capacity above
    this, and ``api.middleware.RateLimitMiddleware._bucket_key`` for what a
    "bucket" is).

    Raise this if legitimate traffic is getting 429s under normal use
    (more concurrent analysts, or a UI that legitimately fires several
    requests per interaction); lower it if a single caller should be
    throttled harder than this — e.g. a deployment that expects only a
    handful of analysts and wants a tighter ceiling on a misbehaving
    client or a scripting mistake.

    Old default was ``60`` — chosen back when the bucket was keyed on raw
    IP alone. In the shape this product actually ships in (one web UI,
    one shared service key, ten-plus analysts sitting behind it), the
    bucket key becomes ``principal:<id>|ip:<ip>`` (see
    ``RateLimitMiddleware._bucket_key``'s docstring) but a single web UI
    still means every analyst using it shares **one IP** too — so ``60``
    req/min was, in practice, 60 requests per minute for the *entire
    organisation*, not per analyst. Ten people each asking one question
    every few seconds blew through it. ``600`` (10 req/sec sustained)
    assumes up to roughly 30 analysts each firing an interactive query —
    a human clicking "ask" every few seconds, not a batch client — at
    once: 30 analysts x one request every 3s is exactly 600/min. A
    deployment with a different expected head-count should raise or
    lower this proportionally rather than trust the default blindly."""

    rate_limit_window_seconds: float = field(
        default_factory=lambda: float(os.getenv("RATE_LIMIT_WINDOW_SEC", "60"))
    )
    """Length (seconds) of the sliding window :attr:`rate_limit_requests`
    refills over. Unchanged from the original default (``60``) — the
    *rate* (:attr:`rate_limit_requests` per this many seconds) is what
    needed raising for real deployment traffic, not the window length
    itself; a shorter window makes the same requests/window ratio bursts
    tighter over any sub-window, a longer one loosens it, but ``60`` is a
    reasonable "per minute" unit for a human operator reading a 429 body
    to reason about either way."""

    rate_limit_burst: int = field(
        default_factory=lambda: int(os.getenv("RATE_LIMIT_BURST", "40"))
    )
    """Extra tokens above :attr:`rate_limit_requests` a bucket can spend
    instantly (bucket capacity = ``rate_limit_requests + rate_limit_burst``
    — see ``api.middleware.RateLimitMiddleware``). Old default was ``10``.
    Raised to ``40`` alongside :attr:`rate_limit_requests`'s five-fold
    increase so the burst allowance scales with it — enough to absorb a
    web UI firing a handful of parallel calls on one page load (e.g. an
    initial dashboard fetch) without immediately eating into the
    sustained rate, while staying far short of "unlimited": a genuinely
    runaway or scripted caller still exhausts 640 tokens (600 + 40) in
    well under a minute at any real request rate and starts getting 429s,
    it just is not punished for one legitimate burst."""

    # ── Admin panel, phase 2: the application database ──────────────────────
    app_db_url: str = field(
        default_factory=lambda: os.getenv("APP_DB_URL", "")
    )
    """A SQLAlchemy URL for the application database — the key store, role
    grants, and (see :mod:`session.persistence`) conversational sessions.
    Empty (the default) falls back to a SQLite file at
    :attr:`app_db_sqlite_path`, created automatically. A configured value
    is expected to name a database that **already exists** — this
    application creates tables inside it, never the database itself (see
    ``docs/admin-panel-architecture.md`` §5.3): ``CREATE DATABASE`` needs
    rights a DBA will not grant an application.

    Must never resolve to the same server+database as
    :attr:`db_connection_url` — or, with :mod:`database.datasources`
    configured (``project_config/datasources.yaml``), any configured
    warehouse data source — :func:`appdb.engine.raise_if_same_database` is
    checked at start-up (``api/server.py``'s ``lifespan``, once per
    source) and refuses to start otherwise, because every warehouse
    connection is deliberately read-only (``docs/db-hardening.md``) and
    the application database needs writes."""

    app_db_sqlite_path: str = field(
        default_factory=lambda: os.getenv("APP_DB_SQLITE_PATH", "logs/app.db")
    )
    """SQLite file used when :attr:`app_db_url` is unset. Defaults to
    ``logs/app.db`` — alongside ``logs/sessions.db`` and the audit log,
    introducing no new category of stored-data location. ``*.db`` is
    already gitignored (see ``tests/test_no_runtime_artifacts_tracked.py``)."""

    key_cache_ttl_seconds: float = field(
        default_factory=lambda: float(os.getenv("KEY_CACHE_TTL_SECONDS", "5.0"))
    )
    """How long (seconds) the in-memory API-key cache
    (:mod:`appdb.key_store`) serves a database-backed key set before
    re-querying. Keys move into the application database precisely so a
    revoked/disabled key can be shut off without a restart
    (``docs/admin-panel-architecture.md`` §5.5/§5.6) — reading the key
    table on every single request would mean a network round trip to the
    application database per request, so this cache exists to avoid that.
    Every mutation (issue/disable/enable/revoke/ACL change/role grant)
    invalidates the cache explicitly rather than waiting out this TTL, so
    it bounds only the *unforced* staleness window — a revocation always
    takes effect on the very next request regardless of this value. Kept
    short by default because it is a safety-relevant staleness bound, not
    a raw performance knob; raise it only if the application database is
    under measured load from key lookups alone."""

    # ── Phase 8: API-key authentication ─────────────────────────────────────
    api_keys_json: str = field(
        default_factory=lambda: os.getenv("API_KEYS_JSON", "")
    )
    """JSON array of ``{"id", "name", "key_sha256", "denied_columns"?}``
    objects — the deployment's configured API keys. Only the SHA-256 hex
    digest of a key is ever stored here, never the raw key itself; issue
    new keys with ``scripts/issue_api_key.py``. Parsed at call time by
    :func:`security.auth.load_api_keys`, per this module's
    read-through-``cfg.settings``-at-call-time convention. Empty (the
    default) means no caller can authenticate — see :attr:`auth_required`
    for what that implies at startup. A multi-line value only survives
    ``.env`` wrapped in single quotes with no apostrophe inside, so for more
    than one key prefer :attr:`api_keys_file`. Setting both is refused."""

    api_keys_file: str = field(
        default_factory=lambda: os.getenv("API_KEYS_FILE", "")
    )
    """Path to a file holding exactly what :attr:`api_keys_json` holds: the
    JSON array of key objects, in any formatting, with no ``.env`` quoting
    rules to trip over. Recommended location: ``project_config/api_keys.json``
    (``project_config/`` is git-ignored). A relative path resolves against the
    repository root, like ``PROJECT_CONFIG_DIR``, not the working directory.
    Empty (the default) means unused. Read once per process, like an
    environment variable, so edits need a restart. A missing or unreadable
    file, invalid JSON, a repeated key inside one object, or both this and
    :attr:`api_keys_json` being set is refused at startup. Read by
    :func:`security.auth.load_api_keys`."""

    auth_required: bool = field(
        default_factory=lambda: os.getenv("AUTH_REQUIRED", "true").lower()
        not in ("0", "false", "no")
    )
    """Whether every non-``/health`` route requires a valid API key.
    Defaults to ``True`` — this is a fail-closed system, not a fail-open
    one. Setting this to ``False`` is a deliberate escape hatch (local
    development, an isolated network with its own perimeter security)
    that ``api/server.py``'s ``lifespan`` logs a ``WARNING`` for on
    *every* startup, not just the first, so a silently-disabled front
    door is never quiet in the logs. When ``True`` and :attr:`api_keys_json`
    / :attr:`api_keys_file` resolve to zero configured keys, ``lifespan``
    raises ``RuntimeError`` instead of starting a server nobody could ever
    authenticate to."""

    app_docs_public: bool = field(
        default_factory=lambda: os.getenv("APP_DOCS_PUBLIC", "false").lower()
        in ("1", "true", "yes")
    )
    """When ``False`` (the default), ``/docs``, ``/redoc``, and
    ``/openapi.json`` require the same authentication as every other
    non-``/health`` route — the generated API documentation describes
    exactly what a caller can do to production data, which is not
    something to publish to an unauthenticated network. Set ``True`` to
    serve them without credentials (e.g. a deployment that already sits
    behind its own perimeter auth)."""

    # ── Phase 5b: deterministic value resolution ────────────────────────────
    resolve_value_timeout_seconds: float = field(
        default_factory=lambda: float(os.getenv("RESOLVE_VALUE_TIMEOUT_SECONDS", "2.0"))
    )
    """Per-(table, column) deadline for ``retrieval.value_resolver.resolve_value``'s
    database round trip. Deliberately much tighter than
    :attr:`query_timeout_seconds` (which bounds a full analytic query) —
    this is a small lookup on the critical path *before* SQL generation
    even starts, so a breach falls back to the no-match path (see
    ``resolve_value``'s docstring) rather than blocking the request for as
    long as a real query is allowed to run."""

    resolve_value_max_concurrency: int = field(
        default_factory=lambda: int(os.getenv("RESOLVE_VALUE_MAX_CONCURRENCY", "8"))
    )
    """How many value-resolution queries may be in flight at once,
    process-wide. This is the bound a ``ThreadPoolExecutor``'s
    ``max_workers`` used to provide, before
    ``retrieval.value_resolver._run_under_deadline`` replaced that pool
    with daemon threads (see its docstring for why it had to). Read at
    call time, so it responds to ``override_settings``. Waiting for a
    free slot spends the caller's own
    :attr:`resolve_value_timeout_seconds`: a saturated resolver must
    report a miss on time rather than queue past its deadline."""

    resolve_value_cache_ttl_seconds: int = field(
        default_factory=lambda: int(os.getenv("RESOLVE_VALUE_CACHE_TTL_SECONDS", "300"))
    )
    """How long (seconds) a cached entity-value resolution stays valid.
    ``<= 0`` disables the cache. Dimension tables (customers, brokers,
    symbols, ...) change slowly, so this can reasonably sit much longer
    than an individual request without serving stale data in practice."""

    resolve_value_cache_max_size: int = field(
        default_factory=lambda: int(os.getenv("RESOLVE_VALUE_CACHE_MAX_SIZE", "512"))
    )
    """Maximum number of distinct ``(mention, table, column, scope_key)``
    resolutions kept in memory before LRU eviction — mirrors
    :attr:`cache_max_size`'s discipline for the unrelated query-result
    cache."""

    dimension_vocabulary_ttl_seconds: int = field(
        default_factory=lambda: int(os.getenv("DIMENSION_VOCABULARY_TTL_SECONDS", "3600"))
    )
    """How long (seconds) a small dimension's prefetched value set
    (``retrieval.dimension_vocabulary``) is served without triggering a
    refresh. Unlike :attr:`resolve_value_cache_ttl_seconds`, this is
    **not** a per-request latency knob and, since the stale-while-revalidate
    redesign, it is **not an expiry either**: a cached value past this TTL
    is still served (a stale trading-hall or commodity name beats no name
    at all) while a background refresh for it is triggered — see that
    module's docstring's "Stale-while-revalidate, warmed lazily" section.
    ``<= 0`` makes every read stale immediately (a background refresh
    fires on every match that consults it, rate-limited on failure by
    :data:`retrieval.dimension_vocabulary._BACKGROUND_REFRESH_BACKOFF_SECONDS`),
    which is the closest this module comes to "disabled" — it still stores
    and serves whatever was last fetched rather than discarding it."""

    dimension_vocabulary_warm_on_startup: bool = field(
        default_factory=lambda: os.getenv(
            "DIMENSION_VOCABULARY_WARM_ON_STARTUP", "true",
        ).lower() in ("1", "true", "yes")
    )
    """When ``True``, ``api/server.py``'s ``lifespan`` calls
    ``retrieval.dimension_vocabulary.warm_all`` once during startup, before
    the server begins accepting requests.

    Its meaning changed with the stale-while-revalidate redesign: the
    vocabulary cache is no longer inert without this flag — a cold or
    stale entry now self-heals via a background refresh triggered from the
    request path itself (never awaited, so no request blocks on it; see
    ``retrieval.dimension_vocabulary``'s module docstring). This flag is
    now a pure **optimisation**: it pre-pays the very first cold-cache miss
    for each dimension at startup instead of leaving it to whichever
    request happens to ask about that dimension first. A deployment that
    turns this off still gets working, self-healing dimension resolution
    — just with one extra miss per dimension after each restart, and (per
    the TTL note above) after each TTL expiry regardless.

    Defaults to ``True``: every restart without
    it reproduces "the first question that mentions any prefetched
    dimension after a cold start silently drops that filter" (self-healing
    only for the *next* question, never the one that triggered the
    refresh) — a real, reported production symptom, not a theoretical one.
    This codebase's test suite is unaffected either way: it builds a
    ``TestClient(app)`` against no live database, and ``warm_all`` catches
    and logs a failure per column rather than raising (see below), so a
    startup warm-up attempt against the tests' unmocked/refused connection
    degrades to the same "no match" cache state a disabled flag would
    leave it in, not a test failure. Set this back to ``false`` only to
    restore the old opt-in behaviour (e.g. a deployment whose startup path
    must not touch the database at all). A warm-up failure (e.g. the
    database is unreachable at startup) is logged as a warning, never
    raised — unlike the auth/config checks above this one, an empty
    vocabulary cache degrades every dimension it would have covered to "no
    match" (this phase's universal safe-miss behaviour), not a server that
    cannot start at all."""

    dimension_vocabulary_token_fallback_enabled: bool = field(
        default_factory=lambda: os.getenv(
            "DIMENSION_VOCABULARY_TOKEN_FALLBACK_ENABLED", "true",
        ).lower() in ("1", "true", "yes")
    )
    """When ``True``, ``retrieval.dimension_vocabulary.match_question_against_vocabulary``
    falls back to a distinctive-token score (see that module's docstring,
    "Matching rules") for a table whose cached value never appears as one
    exact contiguous substring of the question — a hall/currency/etc.
    named by only part of its stored value (an inserted word, a different
    modifier order) rather than the exact string.

    Defaults to ``True`` (confirmed root cause:
    "dimension_vocabulary matching requires the ENTIRE cached value to
    appear as one contiguous substring... no token/partial matching" — the
    concrete mechanism behind the reported "the hall named in the question
    is ignored" complaint). The fix is additive and cannot regress an
    already-working exact match (it only ever runs after that pass finds
    nothing), and its scoring only ever counts a value's *distinctive*
    tokens (excluding whatever token is common to most of that column's
    values), with a tie between two distinct values going to the exact
    same clarification path an ambiguous exact match already uses rather
    than a silent guess. Set to ``false`` only if a real deployment's
    dimension values are short/generic enough that this trades away too
    many clean matches for clarifications, or — worst case — a match
    against a value whose distinctive words are scattered through an
    unrelated question."""

    filter_value_check: bool = field(
        default_factory=lambda: os.getenv("FILTER_VALUE_CHECK", "false").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, a statement the guard accepted is checked once, before it
    runs, for string literals compared to a column whose whole value set is
    cached (a table's ``prefetchable_columns``,
    ``retrieval.dimension_vocabulary``) that are not in that set. If there
    are any, the model gets one correction message that names the column,
    says the value does not exist and lists the nearest real values. The
    corrected statement runs only if every checked literal in it is known;
    otherwise the original statement runs exactly as it would have without
    this setting. The check reads the cache only (it never waits on a
    database read), a column with no cached vocabulary is not checked, and
    it can add one model call to a request but never refuse one. See
    ``retrieval.filter_values`` and the runbook's section on it.

    Defaults to ``False``: with it off no request, prompt, statement, audit
    record or response differs from a deployment that never had the check."""

    filter_value_spelling: bool = field(
        default_factory=lambda: os.getenv("FILTER_VALUE_SPELLING", "true").lower()
        in ("1", "true", "yes")
    )
    """When ``True``, a statement the guard accepted has its string-literal
    filters spelled the way the column stores them, before it runs: a literal
    compared to a column whose whole value set is cached (a table's
    ``prefetchable_columns``) that is not one of its values, but equals exactly
    one of them up to Arabic/Persian letter forms (``ي``/``ى`` for ``ی``,
    ``ك`` for ``ک``, presentation forms, ZWNJ), is replaced by that stored
    value; a ``LIKE`` pattern over a column that stores one form of those
    letters gets that form. There is no model call, no database read (the cache
    only), and a statement the guard refuses after the rewrite runs as it was
    written. It runs ahead of ``FILTER_VALUE_CHECK``, so a fixed literal is a
    known value and costs no correction round. See ``retrieval.filter_spelling``
    and the runbook's section on it.

    Defaults to ``True``: the fix acts only on a statement that would
    otherwise compare with a value the column does not contain (it returns
    nothing under a collation that tells the two spellings apart), and the value
    it writes is the column's own, so it cannot match less than the literal did.
    Set it to ``false`` and no request, statement, audit record or response
    differs from one made before the setting existed."""

    # ── Evaluation harness: golden-set regression gate (see eval/baseline.py) ─
    eval_max_accuracy_drop_pct: float = field(
        default_factory=lambda: float(os.getenv("EVAL_MAX_ACCURACY_DROP_PCT", "5.0"))
    )
    """Percentage-point drop in ``accuracy_pct`` (current vs baseline) above
    which ``eval.baseline.compare_to_baseline`` treats a run as regressed.
    E.g. ``5.0`` means baseline 90% -> current 84% (a 6-point drop) fails,
    but baseline 90% -> current 86% (a 4-point drop) does not. A per-deployment
    tuning knob rather than a fixed technical fact: how much accuracy
    variance is tolerable is a product decision that can reasonably differ
    across warehouses. Still overridable per invocation via
    ``python -m eval.cli run --max-accuracy-drop-pct``, which takes
    precedence when passed explicitly."""

    eval_max_latency_p95_increase_pct: float = field(
        default_factory=lambda: float(os.getenv("EVAL_MAX_LATENCY_P95_INCREASE_PCT", "20.0"))
    )
    """Relative percentage increase in ``latency_p95`` (current vs baseline)
    above which a run is considered regressed. E.g. ``20.0`` means a
    baseline p95 of 2.0s tolerates up to 2.4s before failing. Slower or
    more variable hardware legitimately wants a looser tolerance here than
    a deployment on fast, dedicated hardware -- this is the textbook
    "how aggressively to retry/tolerate for *this* hardware" tuning knob.
    Overridable per invocation via
    ``python -m eval.cli run --max-latency-p95-increase-pct``."""

    eval_max_guard_rejection_increase: int = field(
        default_factory=lambda: int(os.getenv("EVAL_MAX_GUARD_REJECTION_INCREASE", "0"))
    )
    """Absolute increase in ``guard_rejections`` (current vs baseline) above
    which a run is considered regressed. Defaults to ``0`` -- any new guard
    rejection versus baseline is treated as safety-relevant and flagged,
    since it means SQL that used to pass the security guard no longer does
    (or the generator started producing worse SQL). Overridable per
    invocation via ``python -m eval.cli run --max-guard-rejection-increase``."""

    eval_min_accuracy: float | None = field(
        default_factory=lambda: _parse_optional_percent("EVAL_MIN_ACCURACY")
    )
    """Optional absolute floor (percent, 0-100) on the run's overall
    ``accuracy_pct``. ``eval_max_accuracy_drop_pct`` only blocks a *relative*
    drop against a stored baseline, so a baseline that was already poor (or
    a slow slide in steps each smaller than the allowed drop, re-recorded
    every time) never trips it. This is the figure a release is not allowed
    to go below whatever the baseline says. ``None`` (default, variable
    unset or blank) disables the check, so nothing changes until an
    operator sets it. Unlike the drop threshold it needs no
    ``--baseline``. Only meaningful for ``--live`` runs: an offline run
    replays the golden set's own ``expected_sql`` and is 100% by
    construction. Overridable per invocation via
    ``python -m eval.cli run --min-accuracy``."""

    eval_min_source_accuracy: float | None = field(
        default_factory=lambda: _parse_optional_percent("EVAL_MIN_SOURCE_ACCURACY")
    )
    """Optional absolute floor (percent, 0-100) applied to *each* data
    source's execution accuracy separately (the per-source figures in the
    report, built from the golden cases' ``expected_datasource``). An
    overall figure can stay high while one small source collapses; this
    makes that a failure. When set but the run has no per-source figures
    (no golden case names an ``expected_datasource``) the gate fails
    rather than pass unchecked. ``None`` (default) disables the check.
    Overridable per invocation via ``python -m eval.cli run
    --min-source-accuracy``."""

    eval_golden_path: str = field(
        default_factory=lambda: os.getenv("EVAL_GOLDEN_PATH", "eval_data/golden.jsonl")
    )
    """Path to the golden-set ``.jsonl`` file the admin panel's config-version
    dry-run (``docs/admin-panel-architecture.md`` §6.2, phase 3 spec §5)
    runs a candidate ``project_config/`` bundle against before it can be
    applied. Read at call time by :mod:`appdb.config_versions`, the same
    ``eval_data/golden.jsonl`` a deployment already maintains for
    ``python -m eval.cli run`` -- this setting exists so the panel can find
    it without a request body needing to name a filesystem path. Point this
    at ``eval_data.example/golden.jsonl`` (alongside ``PROJECT_CONFIG_DIR``)
    to run the dry-run against the committed example data instead."""

    eval_baseline_path: str = field(
        default_factory=lambda: os.getenv("EVAL_BASELINE_PATH", "eval_data/baseline.json")
    )
    """Path to the baseline JSON file ``python -m eval.cli run --save-baseline``
    writes (:mod:`eval.baseline`). Read at call time by the admin panel's
    feedback-loop stats endpoint (``docs/admin-panel-architecture.md`` §3's
    "closing the loop visibly" -- phase 4 spec §5) to show the golden set's
    most recently recorded accuracy alongside its size and flag volume,
    without re-running the harness against a live endpoint on every panel
    load. A missing file (no baseline has been recorded yet) degrades to
    "no baseline recorded" rather than an error -- this setting exists so
    the panel can find the file without a request needing to name a
    filesystem path, the same reasoning as ``eval_golden_path`` above."""

    config_version_cache_ttl_seconds: float = field(
        default_factory=lambda: float(
            os.getenv("CONFIG_VERSION_CACHE_TTL_SECONDS", "5")
        )
    )
    """How long :mod:`appdb.config_versions` may reuse a cached active
    version id before re-reading it, mirroring ``key_cache_ttl_seconds``
    and existing for the same reason: the id is on the per-request path
    (``api/runner.py`` folds it into every query-cache key, so an applied
    configuration version invalidates stale answers), and reading it from
    the application database on every question would be a network round
    trip per request on an external backend.

    Applying a version invalidates this cache explicitly, so a change is
    visible immediately in the process that made it; the TTL is what bounds
    staleness in *other* processes, where the only cost of being late is a
    few cache hits that should have been misses. Set to ``0`` to disable
    the cache and read the id every time."""

    config_export_dir: str = field(
        default_factory=lambda: os.getenv("CONFIG_EXPORT_DIR", "")
    )
    """Directory :mod:`appdb.config_versions` writes the ``project_config/``
    YAML bundle to on every applied version (spec §7) -- offline inspection
    with familiar tools, and an off-box backup, without this system's
    correctness depending on git being installed. Empty (the default)
    disables the export write entirely; a deployment that wants it points
    this at a directory of its own -- optionally one under version control
    with its own remote (``docs/admin-panel-architecture.md`` §6.3: "git as
    an output, not as the engine" -- never this project's own repository)."""

    # ── Admin panel, phase 5: migration between backends ────────────────────
    migration_quiet_window_seconds: float = field(
        default_factory=lambda: float(
            os.getenv("MIGRATION_QUIET_WINDOW_SECONDS", "60")
        )
    )
    """How recent a write to the application database may be before
    :mod:`appdb.migrate` refuses to run (``docs/admin-panel-architecture.md``
    §5.4/§5.4.1, §3 tier 3's not-yet-built maintenance mode). The tool scans
    every migrated table's own timestamp columns (``created_at``,
    ``updated_at``, ...) for the most recent one and compares its age
    against this window; anything younger means the application was still
    writing a moment ago and the copy this tool is about to take may not
    include everything, with no error at the time it is lost (§7).

    ``60`` is a deliberately generous default: this check exists to catch
    "the operator forgot to stop the application", not to shave seconds off
    a maintenance window, and a false refusal here costs an operator one
    re-run a minute later while a false pass risks silently dropped writes.
    A deployment confident in its own shutdown discipline may lower it; one
    whose application takes longer than a minute to fully quiesce should
    raise it instead of routinely overriding the refusal by other means."""

    # ── Admin panel, phase 6: the operational tier ───────────────────────────
    maintenance_drain_deadline_seconds: int = field(
        default_factory=lambda: int(os.getenv("MAINTENANCE_DRAIN_DEADLINE_SECONDS", "120"))
    )
    """How long (seconds) an operator should expect in-flight requests to
    take to drain after maintenance mode is switched on (``api.maintenance``).

    Purely informational: this codebase has no mechanism that forcibly
    cancels a request already past its admission check (see
    ``api.maintenance.require_not_in_maintenance``'s docstring for why
    "drain" falls out of *where* the check runs, not from any cancellation
    logic) — killing a query mid-execution buys nothing and loses the
    analyst their answer, per ``docs/admin-panel-architecture.md``'s
    maintenance-mode definition. This value is returned alongside the
    toggle-on response so an operator has a concrete number to watch
    rather than guessing: if every in-flight request has not finished by
    the time this many seconds have passed, treat that as a hang worth
    investigating (a stuck LLM call, a wedged database connection), not as
    evidence the switch failed. ``120`` is deliberately generous — comfortably
    above :attr:`query_timeout_seconds` plus the self-correction rounds a
    single query may spend."""

    admin_action_log_retention_days: int = field(
        default_factory=lambda: int(os.getenv("ADMIN_ACTION_LOG_RETENTION_DAYS", "365"))
    )
    """How many days of ``admin_action_log.jsonl`` history
    (:mod:`appdb.admin_audit`) are kept before :func:`appdb.admin_audit.purge_expired_admin_actions`
    discards a record permanently. ``<= 0`` disables the purge entirely —
    keep everything forever, the safest choice for a compliance-sensitive
    deployment that would rather manage disk space by hand than lose
    evidence automatically.

    This log is exempt from the size-based rotation every other JSONL log
    in this project uses (:attr:`log_max_bytes`/:attr:`log_backup_count`)
    precisely so this setting is the ONLY thing that can make an admin
    action disappear. Size-based rotation discards the *oldest* evidence
    first, exactly when there is the *most* activity — which means anyone
    wanting to bury a specific admin action could do so on purpose by
    generating enough unrelated admin noise to roll it off the end of the
    file before anyone reads it. A trail whose whole purpose is "each
    admin role can read that the other one acted"
    (``docs/admin-panel-architecture.md`` §2.4) cannot depend on a
    retention mechanism an interested party can defeat by volume. Retention
    by time closes that path: a record is discarded only once it is
    genuinely older than this many days, never because something noisier
    was appended after it. An on-prem deployment with an externally
    imposed retention requirement sets this explicitly rather than
    accepting the default; see ``docs/deployment-runbook.md``."""

    # ── Admin panel, phase 6: a small, separate bucket for auth FAILURES ────
    # See docs/admin-panel-architecture.md §9's resolved "is IP alone
    # enough for failed admin auth?" question, and
    # api.middleware.RateLimitMiddleware's own docstring for how this
    # bucket is kept separate from the shared, much larger
    # rate_limit_requests bucket every other unauthenticated/authenticated
    # request draws from.
    auth_failure_rate_limit_requests: int = field(
        default_factory=lambda: int(os.getenv("AUTH_FAILURE_RATE_LIMIT_REQUESTS", "20"))
    )
    """Sustained allowance, per window, for requests that presented an
    ``Authorization`` header that failed to resolve to a principal at all
    (a wrong/expired/typo'd key — never a *missing* header, which is
    ordinary unauthenticated traffic and keeps using
    :attr:`rate_limit_requests`'s much larger shared bucket).

    Deliberately small and deliberately separate. Guessing a real key is
    arithmetically impossible (``security.auth``'s keys are
    ``secrets.token_urlsafe(32)`` — 256 bits of entropy), so this is not
    a brute-force defence; tightening it further would be security
    theatre against a threat that does not exist. The actual problem this
    solves: ``api.auth.AuthMiddleware`` runs before the rate limiter, so a
    single misbehaving client looping on a stale/rotated key would
    otherwise consume tokens from the *same* IP-keyed bucket that
    genuinely unauthenticated traffic from that address (a monitoring
    probe behind the same reverse proxy, most concretely) also draws
    from — starving the probe and making the resulting 429 look exactly
    like an outage. A small, separate ceiling for auth failures protects
    that shared budget without touching the limit that actually matters
    for real traffic."""

    auth_failure_rate_limit_window_seconds: float = field(
        default_factory=lambda: float(os.getenv("AUTH_FAILURE_RATE_LIMIT_WINDOW_SEC", "60"))
    )
    """Window (seconds) :attr:`auth_failure_rate_limit_requests` refills
    over — same unit as :attr:`rate_limit_window_seconds`, kept as a
    separate setting so the two buckets' windows can be tuned
    independently."""

    auth_failure_rate_limit_burst: int = field(
        default_factory=lambda: int(os.getenv("AUTH_FAILURE_RATE_LIMIT_BURST", "5"))
    )
    """Extra tokens above :attr:`auth_failure_rate_limit_requests` a single
    auth-failure bucket may spend instantly — mirrors
    :attr:`rate_limit_burst`'s role for the shared bucket, sized much
    smaller to match."""

    def validate(self) -> None:
        """Raise ValueError if any required setting is missing or still a placeholder.

        A ``.env`` line python-dotenv could not use (unparsable, a leftover
        line of a broken multi-line value, or a variable assigned twice with
        different values) is refused first, listing every such line by number
        and variable name -- never by value -- because the settings read below
        may be wrong for exactly that reason. See :mod:`core.dotenv_check`.

        Every warehouse connection string is checked by
        :func:`_check_warehouse_url`: ``db_connection_url`` (with
        ``db_password`` applied) when there is no ``datasources.yaml``,
        otherwise each data source's connection (see
        :mod:`database.datasources`). A structured source whose
        ``username_env`` / ``password_env`` variable is unset or empty is
        refused, naming the source and the variable. So is a ``url_env``
        variable (or ``DB_CONNECTION_URL``) that is unset or empty. Each of
        these adds why the variable is missing
        (:func:`explain_unset_variable`); a connection string that is set but
        wrong does not.
        """
        if _dotenv_problems:
            from core.dotenv_check import format_problems

            raise ValueError(format_problems(_dotenv_path, _dotenv_problems))
        placeholders = {
            "your_password_here", "your_server_here",
            "your_db_here", "change_me", "",
        }
        if not self.openai_model or self.openai_model in placeholders:
            raise ValueError("OPENAI_MODEL is not configured")
        # Fail closed for an unconfigured/unsupported SQL_DIALECT -- an
        # unknown dialect, or one whose profile has no system-catalogue
        # blocklist, must never reach request-serving code (see
        # security.dialects.require_dialect_supported's docstring for why
        # an empty blocklist is refused rather than treated as "nothing to
        # block"). Deferred import: security.dialects has no dependency on
        # config, but importing it at module scope here would still make
        # every `import config` pay for importing security.dialects even
        # when nothing ever calls validate() -- matches this module's own
        # "read at call time" convention for cross-module dependencies.
        from security.dialects import require_dialect_supported

        require_dialect_supported(self.sql_dialect)

        # Every warehouse connection string gets the same checks: the
        # single DB_CONNECTION_URL when there is no datasources.yaml, or
        # each source's connection when there is one (in which case
        # DB_CONNECTION_URL itself is not used and not required). See
        # database.datasources.
        from database.datasources import UnsetVariableError, validate_datasource_urls

        try:
            validate_datasource_urls(
                lambda label, url, dialect: _check_warehouse_url(label, url, dialect, placeholders),
                self,
                placeholders,
            )
        except UnsetVariableError as exc:
            # "not set or is empty" alone points at the wrong place when the
            # variable *is* written in a .env that was never loaded.
            hint = "".join(f"\n  {line}" for line in explain_unset_variable(exc.variable))
            raise UnsetVariableError(f"{exc}{hint}", exc.variable) from exc

        # LLM_EXTRA_BODY is parsed on every request, so a malformed value
        # would otherwise surface as an error on the first question rather
        # than at start-up -- and the operator most likely to set it is one
        # who just watched a reasoning model return nothing and is trying to
        # turn that off. Failing here means a typo is caught by the same
        # pre-flight run (scripts/verify_deployment.py) that already proves
        # the rest of the configuration, instead of by an analyst.
        # Deferred import for the same reason as the two above: llm.providers
        # imports this module.
        from llm.providers import load_extra_body

        load_extra_body()

        if self.llm_prefix_warmup_timeout_seconds <= 0:
            raise ValueError(
                "LLM_PREFIX_WARMUP_TIMEOUT_SECONDS must be greater than zero "
                f"(got {self.llm_prefix_warmup_timeout_seconds}); set "
                "LLM_PREFIX_WARMUP_ON_STARTUP=false to turn the warm-up off instead."
            )

        keepwarm = self.llm_prefix_keepwarm_seconds
        if keepwarm != keepwarm or keepwarm in (float("inf"), float("-inf")) or (
            keepwarm != 0 and keepwarm < 30
        ):
            raise ValueError(
                "LLM_PREFIX_KEEPWARM_SECONDS must be 0 (off) or at least 30 "
                f"(got {keepwarm}); a shorter interval would send the model "
                "server a request every few seconds for no benefit."
            )

        if self.llm_cache_suspect_ms < 0:
            raise ValueError(
                "LLM_CACHE_SUSPECT_MS must be 0 (off) or a number of milliseconds "
                f"(got {self.llm_cache_suspect_ms})."
            )


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    """Return the singleton Settings instance (cached after first call)."""
    return Settings()


# Module-level singleton — ALL modules must access settings as ``cfg.settings``
# (i.e. ``import config as cfg``) so that override_settings() patches are
# visible at call-time rather than being captured at import-time.
settings: Settings = get_settings()


@contextmanager
def override_settings(**kwargs: Any) -> Generator[Settings, None, None]:
    """Context manager for tests: temporarily replace ``cfg.settings``.

    Because all consumers read ``cfg.settings`` lazily (not via a local
    ``from config import settings`` binding), every module sees the new
    value for the lifetime of the ``with`` block.

    The original singleton is restored on exit, even on exception.

    Usage::

        import config as cfg
        from config import override_settings

        with override_settings(max_rows_returned=5) as s:
            assert s.max_rows_returned == 5
            assert cfg.settings.max_rows_returned == 5
    """
    import config as _cfg  # always the real module object
    original = _cfg.settings
    patched  = Settings(**{
        **{f: getattr(original, f) for f in original.__slots__},
        **kwargs,
    })
    _cfg.settings = patched
    try:
        yield patched
    finally:
        _cfg.settings = original