diff --git a/.env.example b/.env.example
index 4193deb7..85a0dc4d 100644
--- a/.env.example
+++ b/.env.example
@@ -118,6 +118,11 @@ YOLO_MODEL_DISPLAY_NAME=Configured YOLO detector
YOLO_MODEL_VERSION=
YOLO_MODEL_CLASSES=building
YOLO_ENFORCE_VALIDATION_SCOPE=false
+# Required when scope enforcement is enabled. The manifest is bound to exact
+# model bytes and contains the allowed EPSG:4326 validation geometry.
+YOLO_VALIDATION_SCOPE_MANIFEST_PATH=
+YOLO_VALIDATION_SCOPE_MANIFEST_SHA256=
+# Deprecated display metadata; never used as an inference authorization gate.
YOLO_VALIDATED_AREA_NAMES=Mol,Kempen
YOLO_CONFIG_DIR=./storage/ultralytics
YOLO_DEVICE=cpu
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 3128c37e..0f716021 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -359,6 +359,17 @@ class Settings(BaseSettings):
yolo_model_version: str | None = Field(default=None, validation_alias="YOLO_MODEL_VERSION")
yolo_model_classes: str = Field(default="building", validation_alias="YOLO_MODEL_CLASSES")
yolo_enforce_validation_scope: bool = Field(default=False, validation_alias="YOLO_ENFORCE_VALIDATION_SCOPE")
+ yolo_validation_scope_manifest_path: str | None = Field(
+ default=None,
+ validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_PATH",
+ )
+ yolo_validation_scope_manifest_sha256: str | None = Field(
+ default=None,
+ validation_alias="YOLO_VALIDATION_SCOPE_MANIFEST_SHA256",
+ )
+ # Deprecated compatibility field. Mutable Area names are never an
+ # inference authorization boundary; deployments must use the immutable
+ # checksum-bound scope manifest above.
yolo_validated_area_names: str = Field(default="Mol,Kempen", validation_alias="YOLO_VALIDATED_AREA_NAMES")
yolo_device: str = Field(default="cpu", validation_alias="YOLO_DEVICE")
yolo_require_cuda: bool = Field(default=False, validation_alias="YOLO_REQUIRE_CUDA")
diff --git a/backend/app/services/detection_service.py b/backend/app/services/detection_service.py
index ea5d1c4b..329eef3c 100644
--- a/backend/app/services/detection_service.py
+++ b/backend/app/services/detection_service.py
@@ -22,6 +22,7 @@ from app.services.detection_qa_service import DetectionQaService
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
from app.services.model_asset_catalog_service import ModelAssetCatalogService
from app.services.model_registry_service import ModelRegistryService
+from app.services.model_validation_scope_service import ModelValidationScopeService
from app.services.qa_service import QaService
from app.services.quality_service import QualityService
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
@@ -235,16 +236,30 @@ class DetectionService:
@staticmethod
def _validate_model_area_scope(db, dataset: Dataset, settings: Settings) -> None:
- allowed_names = [value.strip().casefold() for value in settings.yolo_validated_area_names.split(",") if value.strip()]
area = db.get(Area, dataset.area_id) if dataset.area_id else None
- area_name = area.name.strip() if area is not None else ""
- if not area_name or not any(token in area_name.casefold() for token in allowed_names):
+ if area is None or area.geometry is None:
raise AppError(
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
- message="Configured YOLO inference is not validated for this Dataset area.",
- details={"dataset_id": str(dataset.id), "dataset_area": area_name or None, "validated_area_names": allowed_names},
+ message="Configured YOLO inference requires a persisted Dataset area geometry.",
+ details={"dataset_id": str(dataset.id)},
status_code=422,
)
+ try:
+ area_geometry = to_shape(area.geometry)
+ except Exception as exc:
+ raise AppError(
+ code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
+ message="The persisted Dataset area geometry cannot be validated for model inference.",
+ details={"dataset_id": str(dataset.id), "error_type": type(exc).__name__},
+ status_code=422,
+ ) from exc
+ ModelValidationScopeService.assert_area_covered(
+ area_geometry=area_geometry,
+ manifest_path=settings.yolo_validation_scope_manifest_path,
+ expected_manifest_sha256=settings.yolo_validation_scope_manifest_sha256,
+ model_id=settings.yolo_model_id,
+ model_path=settings.yolo_model_path,
+ )
@staticmethod
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
try:
diff --git a/backend/app/services/model_validation_scope_service.py b/backend/app/services/model_validation_scope_service.py
new file mode 100644
index 00000000..d1cbb0f4
--- /dev/null
+++ b/backend/app/services/model_validation_scope_service.py
@@ -0,0 +1,142 @@
+"""Checksum-bound geographic validation scope for production model inference."""
+
+from __future__ import annotations
+
+from hashlib import sha256
+import json
+from pathlib import Path
+import re
+from typing import Any
+
+from shapely.geometry import shape
+from shapely.geometry.base import BaseGeometry
+
+from app.core.errors import AppError
+
+
+_SHA256 = re.compile(r"^[0-9a-f]{64}$")
+
+
+class ModelValidationScopeService:
+ """Load an immutable, model-bound AOI and prove that an input is covered."""
+
+ SCHEMA_VERSION = "geointel.model-validation-scope/v1"
+
+ @classmethod
+ def assert_area_covered(
+ cls,
+ *,
+ area_geometry: BaseGeometry,
+ manifest_path: str | None,
+ expected_manifest_sha256: str | None,
+ model_id: str,
+ model_path: str | None,
+ ) -> dict[str, str]:
+ path = Path(manifest_path).expanduser() if manifest_path else None
+ expected_checksum = (expected_manifest_sha256 or "").strip().lower()
+ if path is None or not expected_checksum:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_NOT_CONFIGURED",
+ "Configured YOLO inference requires a checksum-bound geographic validation-scope manifest.",
+ )
+ if not _SHA256.fullmatch(expected_checksum):
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_INVALID",
+ "The configured validation-scope checksum must be a lowercase SHA-256 digest.",
+ manifest_path=str(path),
+ )
+ try:
+ raw_manifest = path.read_bytes()
+ payload = json.loads(raw_manifest.decode("utf-8"))
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_INVALID",
+ "The configured validation-scope manifest is missing or unreadable.",
+ manifest_path=str(path),
+ error_type=type(exc).__name__,
+ )
+ observed_manifest_sha256 = sha256(raw_manifest).hexdigest()
+ if observed_manifest_sha256 != expected_checksum:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH",
+ "The validation-scope manifest does not match its configured checksum.",
+ manifest_path=str(path),
+ expected=expected_checksum,
+ observed=observed_manifest_sha256,
+ )
+ if not isinstance(payload, dict) or payload.get("schema_version") != cls.SCHEMA_VERSION:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_INVALID",
+ "The validation-scope manifest has an unsupported schema.",
+ manifest_path=str(path),
+ )
+ if payload.get("model_id") != model_id:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_MODEL_MISMATCH",
+ "The validation scope is not bound to the selected model identity.",
+ expected_model_id=model_id,
+ observed_model_id=payload.get("model_id"),
+ )
+ configured_model_path = Path(model_path).expanduser() if model_path else None
+ if configured_model_path is None or not configured_model_path.is_file():
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_MODEL_UNAVAILABLE",
+ "The model bytes bound by the validation scope are unavailable.",
+ )
+ declared_model_sha256 = str(payload.get("model_sha256") or "").strip().lower()
+ observed_model_sha256 = cls._file_sha256(configured_model_path)
+ if not _SHA256.fullmatch(declared_model_sha256) or declared_model_sha256 != observed_model_sha256:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_MODEL_MISMATCH",
+ "The validation scope is not bound to the exact selected model bytes.",
+ expected=declared_model_sha256 or None,
+ observed=observed_model_sha256,
+ )
+ if payload.get("crs") != "EPSG:4326":
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_INVALID",
+ "The validation-scope geometry must explicitly use EPSG:4326.",
+ observed_crs=payload.get("crs"),
+ )
+ try:
+ scope_geometry = shape(payload.get("geometry"))
+ except Exception as exc:
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_INVALID",
+ "The validation-scope geometry is not valid GeoJSON.",
+ error_type=type(exc).__name__,
+ )
+ if (
+ scope_geometry.is_empty
+ or not scope_geometry.is_valid
+ or scope_geometry.geom_type not in {"Polygon", "MultiPolygon"}
+ ):
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_INVALID",
+ "The validation scope must be a non-empty valid Polygon or MultiPolygon.",
+ geometry_type=scope_geometry.geom_type,
+ )
+ if area_geometry.is_empty or not area_geometry.is_valid or not scope_geometry.covers(area_geometry):
+ cls._raise(
+ "DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
+ "Configured YOLO inference is not validated for the complete Dataset area.",
+ scope_key=payload.get("scope_key"),
+ )
+ return {
+ "scope_key": str(payload.get("scope_key") or "unspecified"),
+ "manifest_path": str(path.resolve()),
+ "manifest_sha256": observed_manifest_sha256,
+ "model_sha256": observed_model_sha256,
+ }
+
+ @staticmethod
+ def _file_sha256(path: Path) -> str:
+ digest = sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+ @staticmethod
+ def _raise(code: str, message: str, **details: Any) -> None:
+ raise AppError(code=code, message=message, details=details, status_code=422)
diff --git a/backend/tests/test_sprint122_model_asset_activation_guardrails.py b/backend/tests/test_sprint122_model_asset_activation_guardrails.py
index 28884bb5..3e06bd05 100644
--- a/backend/tests/test_sprint122_model_asset_activation_guardrails.py
+++ b/backend/tests/test_sprint122_model_asset_activation_guardrails.py
@@ -25,9 +25,9 @@ def test_detection_lab_explains_explicit_model_asset_and_threshold_selection() -
assert "Lokaal modelbestand" in lab
assert "GeoIntel kiest automatisch het actieve lokale model" in lab
- assert "Gevalideerde YOLO-profielen" in lab
+ assert "Historische YOLO-controleprofielen" in lab
assert "DETECTION_OPERATOR_PROFILES" in lab
- assert "kandidaat, extra controle vereist" in lab
+ assert "historisch, geen releasebewijs" in lab
assert "will_download_models" in lab
diff --git a/backend/tests/test_sprint155_detection_operator_profiles.py b/backend/tests/test_sprint155_detection_operator_profiles.py
index 476a0cb6..260a3194 100644
--- a/backend/tests/test_sprint155_detection_operator_profiles.py
+++ b/backend/tests/test_sprint155_detection_operator_profiles.py
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
-def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promoted_profile() -> None:
+def test_detection_operator_profiles_define_explicit_historical_yolo_controls_without_promotion_claim() -> None:
profiles = ROOT / "frontend" / "src" / "components" / "detection" / "detectionProfiles.ts"
source = profiles.read_text(encoding="utf-8")
@@ -17,16 +17,17 @@ def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promote
assert "conservative-review" in source
assert "confidenceThreshold: 0.15" in source
assert "confidenceThreshold: 0.35" in source
- assert "defaultApproved: true" in source
- assert "promotionRecommendation: 'promote_candidate'" in source
+ assert "defaultApproved" not in source
+ assert "promotionRecommendation" not in source
+ assert "independentTestProven: false" in source
assert "positiveSampleCount: 7" in source
assert "precision: 0.6140895327792112" in source
assert "recall: 0.6062221049337548" in source
assert "f1: 0.6068607646002744" in source
assert "f1: 0.5432865390636915" in source
assert "maxBackgroundDetections: 0" in source
- assert "lege-achtergrondtest is geslaagd" in source
- assert "Postel blijft met 47,5% F1" in source
+ assert "Slechts drie pure-achtergrondbeelden" in source
+ assert "ruimtelijke onafhankelijkheid niet bewezen" in source
assert "controlekandidaat en niet als grondwaarheid" in source
@@ -39,11 +40,11 @@ def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> Non
)
assert "DETECTION_OPERATOR_PROFILES" in lab
- assert "Gevalideerde YOLO-profielen" in lab
+ assert "Historische YOLO-controleprofielen" in lab
assert "profile.displayName" in lab
assert "profile.confidenceThreshold" in lab
- assert "kandidaat, extra controle vereist" in lab
- assert "standaardprofiel" in lab
+ assert "historisch, geen releasebewijs" in lab
+ assert "historische F1" in lab
assert "Profiel gebruiken" in lab
assert "onApplyOperatorProfile(profile)" in lab
assert "Recommended starting threshold: 0.25" not in lab
diff --git a/backend/tests/test_sprint193_end_user_workbench.py b/backend/tests/test_sprint193_end_user_workbench.py
index b6c70da0..ee37c816 100644
--- a/backend/tests/test_sprint193_end_user_workbench.py
+++ b/backend/tests/test_sprint193_end_user_workbench.py
@@ -72,7 +72,7 @@ def test_visible_ai_and_quality_labels_are_end_user_facing() -> None:
providers = read("frontend/src/components/providers/ProviderPanel.tsx")
assert "Aanbevolen controleprofiel kleine gebouwen" in profiles
- assert "Postel blijft met 47,5% F1" in profiles
+ assert "Slechts drie pure-achtergrondbeelden" in profiles
assert "controlekandidaat en niet als grondwaarheid" in profiles
assert "qualityStatusLabel" in quality
assert "nog niet uitgevoerd" in quality
diff --git a/backend/tests/test_sprint8b_yolo_foundation.py b/backend/tests/test_sprint8b_yolo_foundation.py
index a42eb6f0..eafa1b23 100644
--- a/backend/tests/test_sprint8b_yolo_foundation.py
+++ b/backend/tests/test_sprint8b_yolo_foundation.py
@@ -8,6 +8,8 @@ from types import SimpleNamespace
from uuid import uuid4
import pytest
+from geoalchemy2.shape import from_shape
+from shapely.geometry import box, mapping
from app.core.config import Settings
from app.core.errors import AppError
@@ -15,6 +17,7 @@ from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, Sour
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
from app.services.detection_service import DetectionService
from app.services.model_registry_service import ModelRegistryService
+from app.services.model_validation_scope_service import ModelValidationScopeService
from app.services.runtime_model_provenance_service import RuntimeModelProvenanceService
from app.services.yolo_adapter import YoloDetectionAdapter
@@ -207,6 +210,28 @@ def _settings(tmp_path: Path, **overrides) -> Settings:
return Settings(**values)
+def _scope_settings(tmp_path: Path, scope_geometry=None, **overrides) -> Settings:
+ model_path = tmp_path / "model.pt"
+ model_path.write_bytes(b"scope-bound-model")
+ payload = {
+ "schema_version": ModelValidationScopeService.SCHEMA_VERSION,
+ "model_id": "yolo-configured",
+ "model_sha256": sha256(model_path.read_bytes()).hexdigest(),
+ "scope_key": "mol-kempen-test",
+ "crs": "EPSG:4326",
+ "geometry": mapping(scope_geometry or box(4.0, 50.8, 5.5, 52.0)),
+ }
+ manifest_path = tmp_path / "model-validation-scope.json"
+ manifest_path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
+ values = {
+ "yolo_model_path": str(model_path),
+ "yolo_validation_scope_manifest_path": str(manifest_path),
+ "yolo_validation_scope_manifest_sha256": sha256(manifest_path.read_bytes()).hexdigest(),
+ }
+ values.update(overrides)
+ return _settings(tmp_path, **values)
+
+
def _write_model_sidecar(
model_path: Path,
settings: Settings,
@@ -399,21 +424,49 @@ def test_yolo_runtime_rejects_cpu_device_when_cuda_is_required(tmp_path: Path, m
def test_yolo_validation_scope_requires_persisted_validated_area(tmp_path: Path) -> None:
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
- wrong_area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Brussels", geometry="MULTIPOLYGON EMPTY")
+ wrong_area = Area(
+ id=dataset.area_id,
+ project_id=dataset.project_id,
+ name="Mol validation bypass",
+ geometry=from_shape(box(-74.1, 40.6, -73.8, 40.9), srid=4326),
+ )
db = FakeSession(objects={(Area, dataset.area_id): wrong_area})
with pytest.raises(AppError) as exc_info:
- DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
+ DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_UNAVAILABLE"
def test_yolo_validation_scope_accepts_bound_mol_area(tmp_path: Path) -> None:
dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
- area = Area(id=dataset.area_id, project_id=dataset.project_id, name="Gemeente Mol", geometry="MULTIPOLYGON EMPTY")
+ area = Area(
+ id=dataset.area_id,
+ project_id=dataset.project_id,
+ name="Een wijzigbare weergavenaam",
+ geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
+ )
db = FakeSession(objects={(Area, dataset.area_id): area})
- DetectionService._validate_model_area_scope(db, dataset, _settings(tmp_path, yolo_validated_area_names="Mol,Kempen"))
+ DetectionService._validate_model_area_scope(db, dataset, _scope_settings(tmp_path))
+
+
+def test_yolo_validation_scope_rejects_tampered_manifest(tmp_path: Path) -> None:
+ dataset = Dataset(id=uuid4(), project_id=uuid4(), name="image.tif", dataset_type="raster", source="test", area_id=uuid4())
+ area = Area(
+ id=dataset.area_id,
+ project_id=dataset.project_id,
+ name="Gemeente Mol",
+ geometry=from_shape(box(5.0, 51.1, 5.2, 51.3), srid=4326),
+ )
+ settings = _scope_settings(tmp_path)
+ Path(settings.yolo_validation_scope_manifest_path).write_text("{}", encoding="utf-8")
+ db = FakeSession(objects={(Area, dataset.area_id): area})
+
+ with pytest.raises(AppError) as exc_info:
+ DetectionService._validate_model_area_scope(db, dataset, settings)
+
+ assert exc_info.value.code == "DETECTION_VALIDATION_SCOPE_CHECKSUM_MISMATCH"
def test_yolo_run_requires_tile_manifest_path(tmp_path: Path) -> None:
diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one
index 52d83233..8d41da6c 100644
--- a/deploy/unraid/Dockerfile.all-in-one
+++ b/deploy/unraid/Dockerfile.all-in-one
@@ -113,6 +113,7 @@ COPY scripts/provision_buildings_addresses_register.py /app/scripts/provision_bu
COPY scripts/provision_regional_timeseries.py /app/scripts/provision_regional_timeseries.py
COPY scripts/geographic_scopes.py /app/scripts/geographic_scopes.py
COPY scripts/provision_geographic_scope.py /app/scripts/provision_geographic_scope.py
+COPY scripts/build_model_validation_scope_manifest.py /app/scripts/build_model_validation_scope_manifest.py
COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/provision_belgium_north_sea_scope.py
COPY scripts/provision_release_golden_areas.py /app/scripts/provision_release_golden_areas.py
COPY scripts/provision_regional_grb_buildings.py /app/scripts/provision_regional_grb_buildings.py
diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml
index ae02ca04..6aae8de3 100644
--- a/deploy/unraid/geointel-unraid-template.xml
+++ b/deploy/unraid/geointel-unraid-template.xml
@@ -137,8 +137,10 @@
{yoloRuntimeReady ? `${yoloPreflight?.runtime.cuda_available ? 'GPU' : 'CPU'} ยท lokaal model gevonden` : 'Controleer de modelconfiguratie onder beheer.'}
{selectedOperatorProfile ? selectedOperatorProfile.validationScope : 'Kies een modelprofiel met gedocumenteerd evaluatiebewijs.'}
{profile.description}
{profile.limitationMessage}