fix(3-L.5): per-backend settings schemas (fixes build_enrichers TypeError)

Surfaced during the 2026-05-20 NaviBackend activation: toggling
config.enrichment.backend_class to NoOpBackend while backend_settings still
held {"base_url": ...} crashed _rebuild_enrichers with
`TypeError: NoOpBackend() takes no arguments`, BEFORE invalidate() ran. Fixed
by mirroring the SourceAdapter.settings_schema pattern: each backend declares a
Pydantic settings_schema; validation happens at write-time (GUI POST) and
read-time (supervisor). A mismatch is now a clean ValidationError, never a
constructor TypeError.

Backends — each gets a `<Name>BackendSettings(BaseModel, extra="forbid")` +
`settings_schema` class attr, mirroring __init__ defaults EXACTLY (note:
timeout_s stays 10.0 — the brief's "5.0" was a transcription slip; preserve the
production default):
  NoOpBackend     -> NoOpBackendSettings        (no fields)
  NaviBackend     -> NaviBackendSettings        (base_url, timeout_s, headers, warmup)
  PhotonBackend   -> PhotonBackendSettings       (base_url, timeout_s, headers)
  NominatimBackend-> NominatimBackendSettings   (base_url, user_agent, rate_limit_per_sec, timeout_s)

GeocoderBackend Protocol (in geocoder.py, where the base actually lives — not
base.py, which only has Enricher) gains `settings_schema: type[BaseModel]`.

supervisor:
- build_enrichers validates backend_cls.settings_schema.model_validate(
  backend_settings) before instantiating, and constructs from the validated
  .model_dump(). ValidationError (not TypeError) on mismatch.
- _rebuild_enrichers builds into locals and commits to instance state only on
  success — a ValidationError leaves the previously-active enrichers/config/
  cache untouched.
- _handle_enrichment_change wraps the rebuild in try/except ValidationError:
  logs and returns, keeping the previous backend running (supervisor stays up;
  operator fixes the row; next NOTIFY applies cleanly). No cache invalidation
  on a failed change.

GUI /enrichment:
- GET skips the outer EnrichmentConfig.backend_settings field and renders a
  separate <fieldset> from describe_fields(backend_cls.settings_schema, ...)
  for the row's current backend_class. Backend fields namespaced bs_<name>.
- POST reassembles bs_<name> inputs into a backend_settings dict, validates it
  against the SUBMITTED backend_class's schema (so errors attach to the right
  fields when an operator is mid-switch), then validates the outer
  EnrichmentConfig. DB row written only if both pass; otherwise re-renders with
  field-level errors against the submitted backend.
- backend_class stays a plain text field (no <select>, no client-side reshape).

form_descriptors: generic `float -> "number"` widget (2 lines, mirrors K.5's
`dict -> "json"`), needed because backend schemas have float fields
(timeout_s, rate_limit_per_sec). Benefits any float field codebase-wide.

DB schema unchanged: backend_settings stays JSONB; validation moved to
use-site. _BACKEND_REGISTRY / _ENRICHER_REGISTRY unchanged beyond schema lookup.

Tests (test_backend_settings_schema.py, 11): schemas exist + extra='forbid';
Navi schema preserves defaults (timeout_s == 10.0); NoOp has zero fields;
build_enrichers raises ValidationError-not-TypeError for the exact 2026-05-20
case; supervisor keeps previous backend on a bad NOTIFY (the incident
scenario); valid NoOp-with-empty-settings applies + invalidates; GUI POST
rejects bad backend_settings without writing + re-renders against submitted
backend; GUI POST writes on valid settings. test_enrichment_config_plumbing
updated for the new context shape (outer_fields/backend_fields).

Verification: full pytest 546 passed, 1 skipped (was 535; +11). grep
subject_for_event/_ADAPTER_REGISTRY and grep 100.64.0./192.168.1. in src both
empty.

Does NOT touch PR L scope (events tab, remaining adapter enrichment_locations),
the DB schema, or the registries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-20 23:10:10 +00:00
commit b694fc0c9d
11 changed files with 493 additions and 63 deletions

View file

@ -16,6 +16,7 @@ import logging
from typing import Any from typing import Any
import aiohttp import aiohttp
from pydantic import BaseModel, ConfigDict, Field
from central.enrichment.geocoder import GEOCODER_FIELDS, all_null_bundle from central.enrichment.geocoder import GEOCODER_FIELDS, all_null_bundle
@ -30,9 +31,24 @@ _WARMUP_LAT = 43.6150
_WARMUP_LON = -116.2023 _WARMUP_LON = -116.2023
class NaviBackendSettings(BaseModel):
"""Settings for NaviBackend. Mirrors __init__ defaults exactly."""
model_config = ConfigDict(extra="forbid")
base_url: str = Field(default=DEFAULT_BASE_URL, description="Navi /api/reverse base URL")
timeout_s: float = Field(default=10.0, description="Per-request timeout in seconds")
headers: dict[str, str] | None = Field(
default=None, description="Extra request headers (e.g. Authorization)"
)
warmup: bool = Field(default=True, description="Fire a warmup ping on construction")
class NaviBackend: class NaviBackend:
"""GeocoderBackend backed by the composed Navi /api/reverse endpoint.""" """GeocoderBackend backed by the composed Navi /api/reverse endpoint."""
settings_schema = NaviBackendSettings
def __init__( def __init__(
self, self,
base_url: str = DEFAULT_BASE_URL, base_url: str = DEFAULT_BASE_URL,

View file

@ -7,11 +7,23 @@ which satisfies the GeocoderBackend contract while resolving nothing.
from typing import Any from typing import Any
from pydantic import BaseModel, ConfigDict
from central.enrichment.geocoder import all_null_bundle from central.enrichment.geocoder import all_null_bundle
class NoOpBackendSettings(BaseModel):
"""No-op backend takes no settings. extra='forbid' makes switching to
NoOpBackend while stale backend_settings (e.g. a base_url) remain a clean
ValidationError instead of a TypeError at construction."""
model_config = ConfigDict(extra="forbid")
class NoOpBackend: class NoOpBackend:
"""GeocoderBackend that resolves no fields.""" """GeocoderBackend that resolves no fields."""
settings_schema = NoOpBackendSettings
async def reverse(self, lat: float, lon: float) -> dict[str, Any]: async def reverse(self, lat: float, lon: float) -> dict[str, Any]:
return all_null_bundle() return all_null_bundle()

View file

@ -16,6 +16,7 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
import aiohttp import aiohttp
from pydantic import BaseModel, ConfigDict, Field
from central.enrichment.geocoder import all_null_bundle from central.enrichment.geocoder import all_null_bundle
@ -25,6 +26,21 @@ DEFAULT_BASE_URL = "https://nominatim.openstreetmap.org"
DEFAULT_USER_AGENT = "central-enrichment/0.5 (https://github.com/zvx-echo6/central)" DEFAULT_USER_AGENT = "central-enrichment/0.5 (https://github.com/zvx-echo6/central)"
class NominatimBackendSettings(BaseModel):
"""Settings for NominatimBackend. Mirrors __init__ defaults exactly."""
model_config = ConfigDict(extra="forbid")
base_url: str = Field(default=DEFAULT_BASE_URL, description="Nominatim /reverse base URL")
user_agent: str = Field(
default=DEFAULT_USER_AGENT, description="User-Agent (public OSM requires one)"
)
rate_limit_per_sec: float = Field(
default=1.0, description="Outbound request cap; 0 disables (self-hosted)"
)
timeout_s: float = Field(default=10.0, description="Per-request timeout in seconds")
class NominatimBackend: class NominatimBackend:
"""GeocoderBackend backed by an OSM Nominatim /reverse endpoint. """GeocoderBackend backed by an OSM Nominatim /reverse endpoint.
@ -32,6 +48,8 @@ class NominatimBackend:
set it to 0 to disable for self-hosted instances. set it to 0 to disable for self-hosted instances.
""" """
settings_schema = NominatimBackendSettings
def __init__( def __init__(
self, self,
base_url: str = DEFAULT_BASE_URL, base_url: str = DEFAULT_BASE_URL,

View file

@ -15,6 +15,7 @@ from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
import aiohttp import aiohttp
from pydantic import BaseModel, ConfigDict, Field
from central.enrichment.geocoder import all_null_bundle from central.enrichment.geocoder import all_null_bundle
@ -23,9 +24,21 @@ logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://localhost:2322" DEFAULT_BASE_URL = "http://localhost:2322"
class PhotonBackendSettings(BaseModel):
"""Settings for PhotonBackend. Mirrors __init__ defaults exactly."""
model_config = ConfigDict(extra="forbid")
base_url: str = Field(default=DEFAULT_BASE_URL, description="Photon /reverse base URL")
timeout_s: float = Field(default=10.0, description="Per-request timeout in seconds")
headers: dict[str, str] | None = Field(default=None, description="Extra request headers")
class PhotonBackend: class PhotonBackend:
"""GeocoderBackend backed by a raw Photon /reverse endpoint.""" """GeocoderBackend backed by a raw Photon /reverse endpoint."""
settings_schema = PhotonBackendSettings
def __init__( def __init__(
self, self,
base_url: str = DEFAULT_BASE_URL, base_url: str = DEFAULT_BASE_URL,

View file

@ -9,6 +9,8 @@ land in PR K.
import logging import logging
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
from pydantic import BaseModel
from central.enrichment.cache import EnrichmentCache from central.enrichment.cache import EnrichmentCache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -38,6 +40,12 @@ def all_null_bundle() -> dict[str, Any]:
class GeocoderBackend(Protocol): class GeocoderBackend(Protocol):
"""The pluggable reverse-geocoding layer beneath GeocoderEnricher.""" """The pluggable reverse-geocoding layer beneath GeocoderEnricher."""
# Pydantic model (extra='forbid') describing this backend's accepted
# settings. The supervisor validates config.enrichment.backend_settings
# against it before instantiating, turning a config/settings mismatch into
# a clean ValidationError instead of a constructor TypeError.
settings_schema: type[BaseModel]
async def reverse(self, lat: float, lon: float) -> dict[str, Any]: async def reverse(self, lat: float, lon: float) -> dict[str, Any]:
"""Return canonical geocoder fields (see GEOCODER_FIELDS). """Return canonical geocoder fields (see GEOCODER_FIELDS).

View file

@ -66,6 +66,8 @@ def _type_to_widget_and_options(field_name: str, field_type: type) -> tuple[str,
return "text", None return "text", None
if field_type is int: if field_type is int:
return "number", None return "number", None
if field_type is float:
return "number", None
if field_type is bool: if field_type is bool:
return "checkbox", None return "checkbox", None
if field_type is RegionConfig: if field_type is RegionConfig:

View file

@ -1995,12 +1995,26 @@ async def streams_update(
# ============================================================================= # =============================================================================
def _enrichment_fields(current: dict) -> list[FieldDescriptor]: def _outer_enrichment_fields(current: dict) -> list[FieldDescriptor]:
"""Field descriptors for the single-row EnrichmentConfig form (generic """EnrichmentConfig form fields EXCEPT backend_settings — that one is
machinery same describe_fields used by adapter pages).""" rendered as a per-backend <fieldset> via _backend_fields()."""
from central.config_models import EnrichmentConfig from central.config_models import EnrichmentConfig
return describe_fields(EnrichmentConfig, current) return [
f for f in describe_fields(EnrichmentConfig, current)
if f.name != "backend_settings"
]
def _backend_fields(backend_class: str | None, current_bs: dict) -> list[FieldDescriptor]:
"""Field descriptors for the selected backend's settings_schema, or [] when
the backend class is unknown. Same generic describe_fields machinery."""
from central.supervisor import _BACKEND_REGISTRY
cls = _BACKEND_REGISTRY.get(backend_class or "")
if cls is None:
return []
return describe_fields(cls.settings_schema, current_bs or {})
async def _read_enrichment_row(conn) -> dict: async def _read_enrichment_row(conn) -> dict:
@ -2013,39 +2027,55 @@ async def _read_enrichment_row(conn) -> dict:
return dict(row) if row is not None else {} return dict(row) if row is not None else {}
def _enrichment_context(request, *, outer_fields, backend_fields, backend_class,
errors=None, form_data=None, backend_form_data=None):
return {
"operator": request.state.operator,
"csrf_token": request.state.csrf_token,
"outer_fields": outer_fields,
"backend_fields": backend_fields,
"backend_class": backend_class,
"errors": errors,
"form_data": form_data,
"backend_form_data": backend_form_data,
}
@router.get("/enrichment", response_class=HTMLResponse) @router.get("/enrichment", response_class=HTMLResponse)
async def enrichment_form(request: Request) -> HTMLResponse: async def enrichment_form(request: Request) -> HTMLResponse:
"""Render the enrichment config form.""" """Render the enrichment config form (outer fields + a per-backend fieldset
for the currently-selected backend_class)."""
templates = _get_templates() templates = _get_templates()
pool = get_pool() pool = get_pool()
operator = request.state.operator
async with pool.acquire() as conn: async with pool.acquire() as conn:
current = await _read_enrichment_row(conn) current = await _read_enrichment_row(conn)
response = templates.TemplateResponse( backend_class = current.get("backend_class") or "NoOpBackend"
current_bs = current.get("backend_settings") or {}
return templates.TemplateResponse(
request=request, request=request,
name="enrichment.html", name="enrichment.html",
context={ context=_enrichment_context(
"operator": operator, request,
"csrf_token": request.state.csrf_token, outer_fields=_outer_enrichment_fields(current),
"fields": _enrichment_fields(current), backend_fields=_backend_fields(backend_class, current_bs),
"errors": None, backend_class=backend_class,
"form_data": None, ),
},
) )
return response
@router.post("/enrichment") @router.post("/enrichment")
async def enrichment_update(request: Request) -> Response: async def enrichment_update(request: Request) -> Response:
"""Validate + persist the enrichment config. Hot-reload picks it up via """Validate + persist the enrichment config. Hot-reload picks it up via the
the config.enrichment NOTIFY trigger.""" config.enrichment NOTIFY trigger. backend_settings is validated against the
SUBMITTED backend_class's settings_schema."""
from central.config_models import EnrichmentConfig from central.config_models import EnrichmentConfig
from central.supervisor import _BACKEND_REGISTRY
templates = _get_templates() templates = _get_templates()
pool = get_pool() pool = get_pool()
operator = request.state.operator
form = await request.form() form = await request.form()
if not form.get("csrf_token") or form.get("csrf_token") != request.state.csrf_token: if not form.get("csrf_token") or form.get("csrf_token") != request.state.csrf_token:
@ -2053,9 +2083,11 @@ async def enrichment_update(request: Request) -> Response:
errors: dict[str, str] = {} errors: dict[str, str] = {}
form_data: dict[str, Any] = {} form_data: dict[str, Any] = {}
backend_form_data: dict[str, Any] = {}
parsed: dict[str, Any] = {} parsed: dict[str, Any] = {}
for field in _enrichment_fields({}): # --- outer EnrichmentConfig fields (backend_settings excluded) ---
for field in _outer_enrichment_fields({}):
raw = form.get(field.name, "") raw = form.get(field.name, "")
form_data[field.name] = raw form_data[field.name] = raw
if field.widget == "number": if field.widget == "number":
@ -2063,25 +2095,49 @@ async def enrichment_update(request: Request) -> Response:
parsed[field.name] = int(raw) if raw else None parsed[field.name] = int(raw) if raw else None
except ValueError: except ValueError:
errors[field.name] = f"{field.label} must be a number" errors[field.name] = f"{field.label} must be a number"
elif field.widget == "json":
if not raw or not raw.strip():
parsed[field.name] = {}
else:
try:
loaded = json.loads(raw)
if not isinstance(loaded, dict):
errors[field.name] = f"{field.label} must be a JSON object"
else:
parsed[field.name] = loaded
except json.JSONDecodeError as e:
errors[field.name] = f"{field.label} is not valid JSON: {e}"
else: # text else: # text
parsed[field.name] = raw.strip() if raw else None parsed[field.name] = raw.strip() if raw else None
submitted_backend_class = parsed.get("backend_class")
# --- backend settings fieldset, validated against the SUBMITTED backend ---
backend_settings: dict[str, Any] = {}
backend_cls = _BACKEND_REGISTRY.get(submitted_backend_class or "")
if backend_cls is None and submitted_backend_class:
errors["backend_class"] = f"Unknown backend: {submitted_backend_class}"
elif backend_cls is not None:
for f in describe_fields(backend_cls.settings_schema, {}):
formkey = f"bs_{f.name}"
raw = form.get(formkey, "")
backend_form_data[formkey] = raw
if f.widget == "checkbox":
backend_settings[f.name] = formkey in form
elif f.widget == "json":
if raw and raw.strip():
try:
backend_settings[f.name] = json.loads(raw)
except json.JSONDecodeError as e:
errors[formkey] = f"{f.label} is not valid JSON: {e}"
# blank -> omit, schema default applies
else: # text / number — let pydantic coerce, omit blanks for defaults
if raw.strip() != "":
backend_settings[f.name] = raw.strip()
if not errors:
try:
backend_settings = backend_cls.settings_schema.model_validate(
backend_settings
).model_dump()
except ValidationError as e:
for err in e.errors():
loc = err["loc"][0] if err["loc"] else "unknown"
errors[f"bs_{loc}"] = err["msg"]
# --- outer EnrichmentConfig validation ---
if not errors: if not errors:
try: try:
validated = EnrichmentConfig( validated = EnrichmentConfig(
**{k: v for k, v in parsed.items() if v is not None} **{k: v for k, v in parsed.items() if v is not None},
backend_settings=backend_settings,
) )
except ValidationError as e: except ValidationError as e:
for err in e.errors(): for err in e.errors():
@ -2089,21 +2145,22 @@ async def enrichment_update(request: Request) -> Response:
errors[str(loc)] = err["msg"] errors[str(loc)] = err["msg"]
if errors: if errors:
async with pool.acquire() as conn: # Re-render against the SUBMITTED backend_class so field errors attach
current = await _read_enrichment_row(conn) # to the right schema (operator may be mid-switch with a typo).
response = templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="enrichment.html", name="enrichment.html",
context={ context=_enrichment_context(
"operator": operator, request,
"csrf_token": request.state.csrf_token, outer_fields=_outer_enrichment_fields({}),
"fields": _enrichment_fields(current), backend_fields=_backend_fields(submitted_backend_class, backend_settings),
"errors": errors, backend_class=submitted_backend_class,
"form_data": form_data, errors=errors,
}, form_data=form_data,
backend_form_data=backend_form_data,
),
status_code=200, status_code=200,
) )
return response
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute( await conn.execute(

View file

@ -7,7 +7,9 @@
<p class="secondary"> <p class="secondary">
Central-side event enrichment. Results are attached to each event under Central-side event enrichment. Results are attached to each event under
<code>data._enriched.&lt;enricher&gt;</code>. Changes hot-reload into the <code>data._enriched.&lt;enricher&gt;</code>. Changes hot-reload into the
supervisor; switching backend invalidates the enrichment cache. supervisor; switching backend invalidates the enrichment cache. Backend
settings below are validated against the selected backend; switching
backend then saving re-renders this form with the new backend's fields.
</p> </p>
<form method="post" action="/enrichment"> <form method="post" action="/enrichment">
@ -15,8 +17,7 @@
<fieldset> <fieldset>
<legend>Configuration</legend> <legend>Configuration</legend>
{% for field in outer_fields %}
{% for field in fields %}
{% if field.widget == "text" %} {% if field.widget == "text" %}
<label for="{{ field.name }}">{{ field.label }}</label> <label for="{{ field.name }}">{{ field.label }}</label>
<input type="text" id="{{ field.name }}" name="{{ field.name }}" <input type="text" id="{{ field.name }}" name="{{ field.name }}"
@ -26,24 +27,59 @@
{% if errors and errors[field.name] %} {% if errors and errors[field.name] %}
<small style="color: var(--pico-color-red-500);">{{ errors[field.name] }}</small> <small style="color: var(--pico-color-red-500);">{{ errors[field.name] }}</small>
{% endif %} {% endif %}
{% elif field.widget == "number" %} {% elif field.widget == "number" %}
<label for="{{ field.name }}">{{ field.label }}</label> <label for="{{ field.name }}">{{ field.label }}</label>
<input type="number" id="{{ field.name }}" name="{{ field.name }}" <input type="number" step="any" id="{{ field.name }}" name="{{ field.name }}"
value="{{ form_data[field.name] if form_data and field.name in form_data else field.current_value or '' }}" value="{{ form_data[field.name] if form_data and field.name in form_data else field.current_value or '' }}"
{% if field.required %}required{% endif %}> {% if field.required %}required{% endif %}>
{% if field.description %}<small>{{ field.description }}</small>{% endif %} {% if field.description %}<small>{{ field.description }}</small>{% endif %}
{% if errors and errors[field.name] %} {% if errors and errors[field.name] %}
<small style="color: var(--pico-color-red-500);">{{ errors[field.name] }}</small> <small style="color: var(--pico-color-red-500);">{{ errors[field.name] }}</small>
{% endif %} {% endif %}
{% endif %}
{% endfor %}
</fieldset>
<fieldset>
<legend>Backend settings — {{ backend_class }}</legend>
{% if not backend_fields %}
<small>This backend takes no settings.</small>
{% endif %}
{% for field in backend_fields %}
{% set fk = "bs_" ~ field.name %}
{% if field.widget == "text" %}
<label for="{{ fk }}">{{ field.label }}</label>
<input type="text" id="{{ fk }}" name="{{ fk }}"
value="{{ backend_form_data[fk] if backend_form_data and fk in backend_form_data else field.current_value or '' }}">
{% if field.description %}<small>{{ field.description }}</small>{% endif %}
{% if errors and errors[fk] %}
<small style="color: var(--pico-color-red-500);">{{ errors[fk] }}</small>
{% endif %}
{% elif field.widget == "number" %}
<label for="{{ fk }}">{{ field.label }}</label>
<input type="number" step="any" id="{{ fk }}" name="{{ fk }}"
value="{{ backend_form_data[fk] if backend_form_data and fk in backend_form_data else field.current_value or '' }}">
{% if field.description %}<small>{{ field.description }}</small>{% endif %}
{% if errors and errors[fk] %}
<small style="color: var(--pico-color-red-500);">{{ errors[fk] }}</small>
{% endif %}
{% elif field.widget == "checkbox" %}
<label>
<input type="checkbox" name="{{ fk }}"
{% if backend_form_data %}{% if backend_form_data[fk] %}checked{% endif %}{% elif field.current_value %}checked{% endif %}>
{{ field.label }}
</label>
{% if field.description %}<small>{{ field.description }}</small>{% endif %}
{% if errors and errors[fk] %}
<small style="color: var(--pico-color-red-500);">{{ errors[fk] }}</small>
{% endif %}
{% elif field.widget == "json" %} {% elif field.widget == "json" %}
<label for="{{ field.name }}">{{ field.label }}</label> <label for="{{ fk }}">{{ field.label }}</label>
<textarea id="{{ field.name }}" name="{{ field.name }}" rows="6" <textarea id="{{ fk }}" name="{{ fk }}" rows="4"
placeholder="{}">{{ form_data[field.name] if form_data and field.name in form_data else (field.current_value | tojson if field.current_value else '{}') }}</textarea> placeholder="{}">{{ backend_form_data[fk] if backend_form_data and fk in backend_form_data else (field.current_value | tojson if field.current_value else '') }}</textarea>
<small>JSON object{% if field.description %} — {{ field.description }}{% endif %}</small> <small>JSON object{% if field.description %} — {{ field.description }}{% endif %}</small>
{% if errors and errors[field.name] %} {% if errors and errors[fk] %}
<small style="color: var(--pico-color-red-500);">{{ errors[field.name] }}</small> <small style="color: var(--pico-color-red-500);">{{ errors[fk] }}</small>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -12,6 +12,7 @@ from typing import Any
import nats import nats
from nats.js import JetStreamContext from nats.js import JetStreamContext
from pydantic import ValidationError
from central.adapter import SourceAdapter from central.adapter import SourceAdapter
from central.adapter_discovery import discover_adapters from central.adapter_discovery import discover_adapters
@ -56,7 +57,12 @@ def build_enrichers(
startup and hot-reloaded via LISTEN/NOTIFY (see Supervisor._on_config_change). startup and hot-reloaded via LISTEN/NOTIFY (see Supervisor._on_config_change).
""" """
backend_cls = _BACKEND_REGISTRY[enrichment_config.backend_class] backend_cls = _BACKEND_REGISTRY[enrichment_config.backend_class]
backend = backend_cls(**enrichment_config.backend_settings) # Validate backend_settings against the backend's settings_schema BEFORE
# constructing. A mismatch (e.g. a stale base_url left in the row after
# switching to NoOpBackend) raises a clean pydantic ValidationError here
# instead of a TypeError inside the backend constructor.
validated = backend_cls.settings_schema.model_validate(enrichment_config.backend_settings)
backend = backend_cls(**validated.model_dump())
enricher_cls = _ENRICHER_REGISTRY[enrichment_config.enricher_class] enricher_cls = _ENRICHER_REGISTRY[enrichment_config.enricher_class]
return [enricher_cls(backend, cache=cache)] return [enricher_cls(backend, cache=cache)]
@ -680,17 +686,27 @@ class Supervisor:
) )
def _rebuild_enrichers(self, config: EnrichmentConfig) -> None: def _rebuild_enrichers(self, config: EnrichmentConfig) -> None:
"""Rebuild the active enricher set + cache from an EnrichmentConfig.""" """Rebuild the active enricher set + cache from an EnrichmentConfig.
Builds into locals first and commits to instance state only on success,
so a ValidationError (bad backend_settings) leaves the previously-active
enrichers/config/cache untouched and propagates to the caller.
"""
cache = EnrichmentCache(ENRICHMENT_CACHE_DB_PATH, ttl_s=config.cache_ttl_s)
enrichers = build_enrichers(config, cache) # may raise ValidationError
self._active_enrichment_config = config self._active_enrichment_config = config
self._enrichment_cache = EnrichmentCache( self._enrichment_cache = cache
ENRICHMENT_CACHE_DB_PATH, ttl_s=config.cache_ttl_s self._enrichers = enrichers
)
self._enrichers = build_enrichers(config, self._enrichment_cache)
async def _handle_enrichment_change(self) -> None: async def _handle_enrichment_change(self) -> None:
"""Re-read config.enrichment and rebuild enrichers. Invalidate the cache """Re-read config.enrichment and rebuild enrichers. Invalidate the cache
when the backend changed, so stale results from the previous backend when the backend changed, so stale results from the previous backend
don't survive until TTL expiry.""" don't survive until TTL expiry.
Invalid backend_settings (ValidationError) leave the previous backend
running the supervisor stays up; the operator fixes the row and the
next NOTIFY brings it in cleanly.
"""
new_config = await self._config_source.get_enrichment_config() new_config = await self._config_source.get_enrichment_config()
old_config = self._active_enrichment_config old_config = self._active_enrichment_config
backend_changed = ( backend_changed = (
@ -698,7 +714,19 @@ class Supervisor:
or new_config.backend_settings != old_config.backend_settings or new_config.backend_settings != old_config.backend_settings
or new_config.enricher_class != old_config.enricher_class or new_config.enricher_class != old_config.enricher_class
) )
try:
self._rebuild_enrichers(new_config) self._rebuild_enrichers(new_config)
except ValidationError as e:
logger.error(
"Enrichment config invalid; keeping previous backend",
extra={
"enricher_class": new_config.enricher_class,
"backend_class": new_config.backend_class,
"backend_settings": new_config.backend_settings,
"errors": e.errors(),
},
)
return
if backend_changed: if backend_changed:
deleted = await self._enrichment_cache.invalidate() deleted = await self._enrichment_cache.invalidate()
logger.info( logger.info(

View file

@ -0,0 +1,235 @@
"""Tests for per-backend settings schemas (PR L.5).
Each GeocoderBackend declares a Pydantic settings_schema (extra='forbid').
build_enrichers validates backend_settings against it BEFORE constructing, so
a config/settings mismatch is a clean ValidationError, not a TypeError. The
supervisor's hot-reload keeps the previous backend running on ValidationError;
the GUI POST re-renders with field errors and does not write the DB row.
Regression guard for the 2026-05-20 incident: switching backend_class to
NoOpBackend while backend_settings still held {"base_url": ...} crashed
_rebuild_enrichers with `TypeError: NoOpBackend() takes no arguments`.
"""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel, ValidationError
from central.config_models import EnrichmentConfig
from central.enrichment.cache import EnrichmentCache
from central.supervisor import _BACKEND_REGISTRY, build_enrichers
# --- schemas exist + extra='forbid' -----------------------------------------
def test_every_backend_declares_a_settings_schema():
for name, cls in _BACKEND_REGISTRY.items():
schema = getattr(cls, "settings_schema", None)
assert schema is not None, f"{name} has no settings_schema"
assert isinstance(schema, type) and issubclass(schema, BaseModel), name
def test_every_settings_schema_forbids_extra():
for name, cls in _BACKEND_REGISTRY.items():
with pytest.raises(ValidationError):
cls.settings_schema.model_validate({"definitely_not_a_field": 1})
def test_navi_schema_accepts_valid_kwargs_and_preserves_defaults():
from central.enrichment.backends.navi import NaviBackendSettings
m = NaviBackendSettings()
assert m.base_url == "http://localhost:8440"
assert m.timeout_s == 10.0 # preserved from __init__, NOT 5.0
assert m.headers is None
assert m.warmup is True
m2 = NaviBackendSettings(base_url="http://navi:8440", timeout_s=3.0, warmup=False)
assert m2.base_url == "http://navi:8440" and m2.timeout_s == 3.0
def test_noop_schema_has_no_fields():
from central.enrichment.backends.no_op import NoOpBackendSettings
assert list(NoOpBackendSettings.model_fields) == []
# --- build_enrichers validation ----------------------------------------------
def _cache(tmp_path) -> EnrichmentCache:
return EnrichmentCache(tmp_path / "c.db")
def test_build_enrichers_raises_validation_error_not_typeerror(tmp_path):
"""The exact 2026-05-20 bug: NoOpBackend + stale base_url. Must be a clean
ValidationError, never a TypeError from the constructor."""
cfg = EnrichmentConfig(backend_class="NoOpBackend", backend_settings={"base_url": "http://x"})
with pytest.raises(ValidationError):
build_enrichers(cfg, _cache(tmp_path))
def test_build_enrichers_navi_valid_settings(tmp_path):
from central.enrichment.backends.navi import NaviBackend
cfg = EnrichmentConfig(
backend_class="NaviBackend",
backend_settings={"base_url": "http://navi:8440", "warmup": False},
)
enrichers = build_enrichers(cfg, _cache(tmp_path))
assert isinstance(enrichers[0]._backend, NaviBackend)
def test_build_enrichers_navi_unknown_setting_rejected(tmp_path):
cfg = EnrichmentConfig(
backend_class="NaviBackend",
backend_settings={"base_url": "http://navi:8440", "bogus": 1},
)
with pytest.raises(ValidationError):
build_enrichers(cfg, _cache(tmp_path))
# --- supervisor hot-reload keeps previous backend on ValidationError ---------
def _supervisor():
from central import supervisor as sup_mod
config_source = MagicMock()
config_source.get_enrichment_config = AsyncMock(return_value=EnrichmentConfig())
return sup_mod.Supervisor(
config_source=config_source,
config_store=MagicMock(),
nats_url="nats://localhost:4222",
)
@pytest.mark.asyncio
async def test_handle_enrichment_change_keeps_previous_on_validation_error():
"""Navi active, then a bad NOTIFY (NoOp + leftover base_url) arrives. The
supervisor must keep NaviBackend running and not crash."""
from central.enrichment.backends.navi import NaviBackend
from central.enrichment.backends.no_op import NoOpBackend
with patch("central.supervisor.EnrichmentCache") as cache_cls:
cache_cls.return_value = MagicMock(invalidate=AsyncMock(return_value=0))
sup = _supervisor()
# Establish a valid NaviBackend active state.
sup._rebuild_enrichers(EnrichmentConfig(
backend_class="NaviBackend",
backend_settings={"base_url": "http://navi:8440", "warmup": False},
))
assert isinstance(sup._enrichers[0]._backend, NaviBackend)
active_before = sup._active_enrichment_config
# Bad config arrives via NOTIFY: NoOp + stale base_url -> ValidationError.
sup._config_source.get_enrichment_config = AsyncMock(
return_value=EnrichmentConfig(
backend_class="NoOpBackend",
backend_settings={"base_url": "http://navi:8440"},
)
)
await sup._handle_enrichment_change() # must NOT raise
# Previous backend still active; config unchanged; cache NOT invalidated.
assert isinstance(sup._enrichers[0]._backend, NaviBackend)
assert sup._active_enrichment_config is active_before
cache_cls.return_value.invalidate.assert_not_awaited()
@pytest.mark.asyncio
async def test_handle_enrichment_change_applies_valid_noop_with_empty_settings():
"""Switching to NoOp with empty backend_settings (the correct way) applies
cleanly and invalidates the cache."""
from central.enrichment.backends.no_op import NoOpBackend
with patch("central.supervisor.EnrichmentCache") as cache_cls:
cache_cls.return_value = MagicMock(invalidate=AsyncMock(return_value=3))
sup = _supervisor()
sup._rebuild_enrichers(EnrichmentConfig(
backend_class="NaviBackend",
backend_settings={"base_url": "http://navi:8440", "warmup": False},
))
sup._config_source.get_enrichment_config = AsyncMock(
return_value=EnrichmentConfig(backend_class="NoOpBackend", backend_settings={})
)
await sup._handle_enrichment_change()
assert isinstance(sup._enrichers[0]._backend, NoOpBackend)
cache_cls.return_value.invalidate.assert_awaited()
# --- GUI POST validates backend_settings + does not write on error ----------
def _mock_pool(conn):
pool = MagicMock()
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=None)
pool.acquire = MagicMock(return_value=cm)
return pool
@pytest.mark.asyncio
async def test_gui_post_rejects_bad_backend_settings_without_writing():
"""POST with a bad backend setting (timeout_s non-numeric) re-renders with a
field error keyed bs_timeout_s and does NOT execute the DB upsert."""
from central.gui.routes import enrichment_update
request = MagicMock()
request.state.operator = MagicMock(username="op")
request.state.csrf_token = "tok"
form = {
"csrf_token": "tok",
"enricher_class": "GeocoderEnricher",
"backend_class": "NaviBackend",
"cache_ttl_s": "86400",
"bs_base_url": "http://navi:8440",
"bs_timeout_s": "not-a-number",
}
request.form = AsyncMock(return_value=form)
conn = MagicMock()
conn.execute = AsyncMock()
conn.fetchrow = AsyncMock(return_value={})
templates = MagicMock()
templates.TemplateResponse.return_value = MagicMock()
with patch("central.gui.routes._get_templates", return_value=templates), \
patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
await enrichment_update(request)
conn.execute.assert_not_awaited() # no DB write on validation failure
ctx = templates.TemplateResponse.call_args.kwargs["context"]
assert "bs_timeout_s" in ctx["errors"]
assert ctx["backend_class"] == "NaviBackend" # re-rendered against SUBMITTED backend
@pytest.mark.asyncio
async def test_gui_post_writes_on_valid_settings():
from central.gui.routes import enrichment_update
request = MagicMock()
request.state.operator = MagicMock(username="op")
request.state.csrf_token = "tok"
form = {
"csrf_token": "tok",
"enricher_class": "GeocoderEnricher",
"backend_class": "NaviBackend",
"cache_ttl_s": "86400",
"bs_base_url": "http://navi:8440",
"bs_timeout_s": "10",
}
request.form = AsyncMock(return_value=form)
conn = MagicMock()
conn.execute = AsyncMock()
templates = MagicMock()
with patch("central.gui.routes._get_templates", return_value=templates), \
patch("central.gui.routes.get_pool", return_value=_mock_pool(conn)):
resp = await enrichment_update(request)
conn.execute.assert_awaited() # DB upsert ran
# the backend_settings written are the validated/dumped dict
args = conn.execute.call_args.args
assert "INSERT INTO config.enrichment" in args[0]
assert resp.status_code == 302

View file

@ -203,6 +203,11 @@ async def test_enrichment_form_renders():
await enrichment_form(request) await enrichment_form(request)
ctx = templates.TemplateResponse.call_args.kwargs["context"] ctx = templates.TemplateResponse.call_args.kwargs["context"]
field_widgets = {f.name: f.widget for f in ctx["fields"]} # PR L.5: outer fields exclude backend_settings (now a per-backend fieldset);
assert field_widgets["backend_settings"] == "json" # NoOpBackend's fieldset has zero fields.
outer = {f.name for f in ctx["outer_fields"]}
assert "backend_settings" not in outer
assert "backend_class" in outer
assert ctx["backend_class"] == "NoOpBackend"
assert ctx["backend_fields"] == []
assert ctx["csrf_token"] == "tok" assert ctx["csrf_token"] == "tok"