diff --git a/CHANGELOG.md b/CHANGELOG.md
index e623c765..863ab60b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@
# Changelog
+## Sprint 223 Governed regional GRB refresh (2026-07-16)
+
+- Added a canonical, read-only regional GRB refresh plan for buildings, roads,
+ water and parcels, derived from the official catalog edition and persisted
+ temporal snapshots.
+- Added an explicit operator coordinator with separate `plan`, `stage` and
+ SHA-256-confirmed `apply` phases. Staging validates every municipality
+ partition before PostGIS persistence is possible.
+- Kept all provider URLs and collections allowlisted, reused the existing
+ resumable regional operators and DatasetService/VectorFeatureService import
+ path, and retained every older snapshot.
+- Added compact Status UI, deterministic guardrail tests and readiness syntax
+ coverage without changing migrations or enabling automatic refresh.
+
## Sprint 222 Official source edition probes (2026-07-16)
- Added explicit, read-only GRB and most-recent orthophoto catalog probes using
diff --git a/backend/README.md b/backend/README.md
index af97d7d3..b265bfa8 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1469,3 +1469,41 @@ Runtime controls are `SOURCE_CATALOG_PROBE_ENABLED`,
`SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS`. Metadata links remain restricted to
the official HTTPS CSW path on `metadata.vlaanderen.be` even when an operator
overrides the capabilities endpoint.
+
+## Governed regional GRB refresh
+
+`GET /api/v1/projects/{project_id}/datasets/grb-refresh-plan` combines the
+explicit official GRB edition probe with the four existing regional snapshot
+series. It is read-only: it reports local feature/storage impact and whether
+buildings, roads, water and parcels are current, updateable or require review.
+
+Regional refreshes use a two-phase operator flow inside the all-in-one
+container. First stage and validate every source partition without touching
+PostGIS:
+
+```bash
+docker exec geointel python /app/scripts/manage_grb_refresh.py stage \
+ --project-id 82a85913-c522-45d7-84a1-02b393d89e55 \
+ --api-url http://127.0.0.1:8000/api/v1 \
+ --confirm-edition 2026-07-15 \
+ --layers buildings roads water parcels
+```
+
+The JSON result gives `plan_path`, exact feature deltas, artifact sizes and
+`plan_sha256`. Review that evidence, then apply those exact staged bytes:
+
+```bash
+docker exec geointel python /app/scripts/manage_grb_refresh.py apply \
+ --project-id 82a85913-c522-45d7-84a1-02b393d89e55 \
+ --api-url http://127.0.0.1:8000/api/v1 \
+ --confirm-edition 2026-07-15 \
+ --confirm-plan-sha256 SHA256_FROM_STAGE
+```
+
+Both commands fail when project/scope, official edition, manifests, partition
+count or any checksum differs. Interrupted staging is safely resumable because
+the existing municipal manifests are reused. Apply imports through
+DatasetService/VectorFeatureService, creates new temporal Datasets and retains
+all previous snapshots. Do not add `--force` to this coordinator; a source
+refetch remains a separate deliberate recovery action in the lower-level
+operators.
diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py
index 212c4cff..fe69b91c 100644
--- a/backend/app/api/routes/datasets.py
+++ b/backend/app/api/routes/datasets.py
@@ -46,6 +46,7 @@ from app.services.vector_feature_service import VectorFeatureService
from app.services.dataset_service import DatasetService
from app.services.source_freshness_service import SourceFreshnessService
from app.services.source_catalog_probe_service import SourceCatalogProbeService
+from app.services.grb_refresh_plan_service import GrbRefreshPlanService
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
from app.services.dhmv_acquisition_service import DhmvAcquisitionService
from app.services.terrain_analysis_service import TerrainAnalysisService
@@ -266,6 +267,22 @@ def probe_dataset_source_catalogs(
return envelope(report.model_dump())
+@router.get("/datasets/grb-refresh-plan", response_model=dict)
+def plan_grb_dataset_refresh(
+ project_id: UUID,
+ scope: str = Query(default=GrbRefreshPlanService.SCOPE),
+ refresh_catalog: bool = Query(default=False),
+ db: Session = Depends(get_db),
+):
+ report = GrbRefreshPlanService.build(
+ db,
+ project_id,
+ scope=scope,
+ refresh_catalog=refresh_catalog,
+ )
+ return envelope(report.model_dump())
+
+
@router.get("/datasets/{dataset_id}", response_model=dict)
def get_dataset(
project_id: UUID,
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index 9bc80ab4..e37cb822 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -16,6 +16,7 @@ from .source_catalog import (
SourceCatalogProbeReport,
SourceCatalogProbeSummary,
)
+from .grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary
from .detection import (
DetectionListResponse,
DetectionModelCapability,
@@ -149,6 +150,9 @@ __all__ = [
"SourceCatalogProbeItem",
"SourceCatalogProbeReport",
"SourceCatalogProbeSummary",
+ "GrbRefreshLayerPlan",
+ "GrbRefreshPlan",
+ "GrbRefreshPlanSummary",
"DetectionListResponse",
"DetectionModelCapability",
"DetectionModelsResponse",
diff --git a/backend/app/schemas/grb_refresh.py b/backend/app/schemas/grb_refresh.py
new file mode 100644
index 00000000..5d439fee
--- /dev/null
+++ b/backend/app/schemas/grb_refresh.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+GrbRefreshLayerStatus = Literal[
+ "current",
+ "update_available",
+ "not_loaded",
+ "review_required",
+ "remote_unavailable",
+]
+
+
+class GrbRefreshLayerPlan(BaseModel):
+ theme: Literal["buildings", "roads", "water", "parcels"]
+ display_name: str
+ collections: list[str]
+ temporal_series_key: str
+ status: GrbRefreshLayerStatus
+ local_dataset_id: UUID | None = None
+ local_source_version: str | None = None
+ local_observed_at: datetime | None = None
+ local_imported_at: datetime | None = None
+ local_feature_count: int | None = None
+ local_size_bytes: int | None = None
+ retained_after_refresh: bool = True
+ action_message: str
+
+
+class GrbRefreshPlanSummary(BaseModel):
+ layer_count: int
+ current_count: int
+ update_available_count: int
+ not_loaded_count: int
+ review_required_count: int
+ remote_unavailable_count: int
+ new_dataset_count_if_applied: int
+ retained_dataset_count: int
+ current_feature_count: int
+ current_size_bytes: int
+
+
+class GrbRefreshPlan(BaseModel):
+ project_id: UUID
+ scope: str
+ generated_at: datetime
+ remote_status: str
+ remote_version: str | None = None
+ remote_edition_date: date | None = None
+ catalog_checked_at: datetime | None = None
+ summary: GrbRefreshPlanSummary
+ layers: list[GrbRefreshLayerPlan]
+ execution_mode: Literal["operator_stage_then_apply"] = "operator_stage_then_apply"
+ staging_required: bool = True
+ automatic_import: bool = False
+ destructive_replacement: bool = False
+ message: str
+ limitations: list[str]
diff --git a/backend/app/services/grb_refresh_plan_service.py b/backend/app/services/grb_refresh_plan_service.py
new file mode 100644
index 00000000..b2a8f781
--- /dev/null
+++ b/backend/app/services/grb_refresh_plan_service.py
@@ -0,0 +1,186 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from datetime import date, datetime, timezone
+from uuid import UUID
+
+from sqlalchemy.orm import Session
+
+from app.core.errors import AppError
+from app.models import Dataset, Project
+from app.schemas.grb_refresh import GrbRefreshLayerPlan, GrbRefreshPlan, GrbRefreshPlanSummary
+from app.services.source_catalog_probe_service import SourceCatalogProbeService
+
+
+@dataclass(frozen=True)
+class _LayerDefinition:
+ theme: str
+ display_name: str
+ collections: tuple[str, ...]
+
+ @property
+ def series_key(self) -> str:
+ return f"grb:{self.theme}:kempen-transport-region"
+
+
+class GrbRefreshPlanService:
+ SCOPE = "kempen-transport-region"
+ LAYERS = (
+ _LayerDefinition("buildings", "Gebouwen", ("GBG",)),
+ _LayerDefinition("roads", "Wegen", ("Wegsegment",)),
+ _LayerDefinition("water", "Water", ("WTZ", "WLAS", "WGR")),
+ _LayerDefinition("parcels", "Percelen", ("ADP",)),
+ )
+ _EDITION_DATE = re.compile(r"(? date | None:
+ match = cls._EDITION_DATE.search(value or "")
+ if not match:
+ return None
+ try:
+ return date.fromisoformat(match.group(1))
+ except ValueError:
+ return None
+
+ @staticmethod
+ def _dataset_feature_count(dataset: Dataset) -> int | None:
+ metadata = dataset.metadata_json if isinstance(dataset.metadata_json, dict) else {}
+ value = metadata.get("feature_count")
+ try:
+ return int(value) if value is not None else None
+ except (TypeError, ValueError):
+ return None
+
+ @staticmethod
+ def _latest_dataset(rows: list[Dataset], series_key: str) -> Dataset | None:
+ candidates = [
+ row
+ for row in rows
+ if row.temporal_series_key == series_key and row.status == "ready"
+ ]
+ if not candidates:
+ return None
+ minimum = datetime.min.replace(tzinfo=timezone.utc)
+ return max(candidates, key=lambda row: (row.observed_at or row.imported_at or row.created_at or minimum, str(row.id)))
+
+ @classmethod
+ def build(
+ cls,
+ db: Session,
+ project_id: UUID,
+ *,
+ scope: str = SCOPE,
+ refresh_catalog: bool = False,
+ now: datetime | None = None,
+ ) -> GrbRefreshPlan:
+ if scope != cls.SCOPE:
+ raise AppError(
+ code="GRB_REFRESH_SCOPE_UNSUPPORTED",
+ message=f"Only the governed scope '{cls.SCOPE}' is supported",
+ status_code=400,
+ )
+ if not db.get(Project, project_id):
+ raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
+
+ generated_at = now or datetime.now(timezone.utc)
+ catalog = SourceCatalogProbeService.audit_project(db, project_id, force=refresh_catalog)
+ grb_probe = next((item for item in catalog.items if item.source_name == "grb"), None)
+ remote_version = grb_probe.remote_version if grb_probe else None
+ remote_edition_date = cls._parse_edition_date(remote_version)
+ remote_available = bool(grb_probe and grb_probe.status == "available" and grb_probe.reachable)
+ rows = (
+ db.query(Dataset)
+ .filter(Dataset.project_id == project_id, Dataset.source_name == "grb")
+ .all()
+ )
+
+ layer_plans: list[GrbRefreshLayerPlan] = []
+ for definition in cls.LAYERS:
+ local = cls._latest_dataset(rows, definition.series_key)
+ local_date = cls._parse_edition_date(local.source_version if local else None)
+ if not remote_available:
+ status = "remote_unavailable"
+ action = "De officiële catalogus is niet bereikbaar; er wordt geen vernieuwingsbeslissing genomen."
+ elif remote_edition_date is None:
+ status = "review_required"
+ action = "De officiële editie bevat geen herkenbare datum en vereist menselijke beoordeling."
+ elif local is None:
+ status = "not_loaded"
+ action = "Deze laag kan als nieuwe, afzonderlijke GRB-snapshot worden voorbereid."
+ elif local_date is None:
+ status = "review_required"
+ action = "De lokale editie is niet veilig datumvergelijkbaar; controleer de provenance vóór staging."
+ elif local_date == remote_edition_date:
+ status = "current"
+ action = "De lokale snapshot gebruikt dezelfde officiële editie; geen import nodig."
+ elif local_date < remote_edition_date:
+ status = "update_available"
+ action = "Stage eerst alle bronartifacts en controleer exacte aantallen en checksums vóór import."
+ else:
+ status = "review_required"
+ action = "De lokale editie lijkt nieuwer dan de catalogus; automatische terugval is verboden."
+
+ layer_plans.append(
+ GrbRefreshLayerPlan(
+ theme=definition.theme,
+ display_name=definition.display_name,
+ collections=list(definition.collections),
+ temporal_series_key=definition.series_key,
+ status=status,
+ local_dataset_id=local.id if local else None,
+ local_source_version=local.source_version if local else None,
+ local_observed_at=local.observed_at if local else None,
+ local_imported_at=local.imported_at if local else None,
+ local_feature_count=cls._dataset_feature_count(local) if local else None,
+ local_size_bytes=local.size_bytes if local else None,
+ action_message=action,
+ )
+ )
+
+ counts = {status: sum(item.status == status for item in layer_plans) for status in (
+ "current", "update_available", "not_loaded", "review_required", "remote_unavailable"
+ )}
+ actionable = counts["update_available"] + counts["not_loaded"]
+ summary = GrbRefreshPlanSummary(
+ layer_count=len(layer_plans),
+ current_count=counts["current"],
+ update_available_count=counts["update_available"],
+ not_loaded_count=counts["not_loaded"],
+ review_required_count=counts["review_required"],
+ remote_unavailable_count=counts["remote_unavailable"],
+ new_dataset_count_if_applied=actionable,
+ retained_dataset_count=sum(item.local_dataset_id is not None for item in layer_plans),
+ current_feature_count=sum(item.local_feature_count or 0 for item in layer_plans),
+ current_size_bytes=sum(item.local_size_bytes or 0 for item in layer_plans),
+ )
+ if actionable:
+ message = (
+ f"{actionable} GRB-laag{' is' if actionable == 1 else 'en zijn'} voorbereidbaar voor editie "
+ f"{remote_edition_date.isoformat() if remote_edition_date else remote_version}. "
+ "Staging berekent eerst de exacte impact; import vereist daarna de plan-checksum."
+ )
+ elif counts["current"] == len(layer_plans):
+ message = "Alle beheerde regionale GRB-lagen gebruiken de officiële cataloguseditie."
+ else:
+ message = "Er is menselijke beoordeling nodig voordat een GRB-staging kan starten."
+
+ return GrbRefreshPlan(
+ project_id=project_id,
+ scope=scope,
+ generated_at=generated_at,
+ remote_status=grb_probe.status if grb_probe else "unavailable",
+ remote_version=remote_version,
+ remote_edition_date=remote_edition_date,
+ catalog_checked_at=grb_probe.checked_at if grb_probe else None,
+ summary=summary,
+ layers=layer_plans,
+ message=message,
+ limitations=[
+ "Dit endpoint is read-only en start geen download, import of databasejob.",
+ "Staging bewaart bronartifacts buiten PostGIS; apply vereist de exacte staged plan-checksum.",
+ "Een refresh maakt nieuwe immutable Datasets en verwijdert of overschrijft oude snapshots niet.",
+ "Exacte feature- en opslagverschillen zijn pas bekend nadat alle regionale partitions staged en gevalideerd zijn.",
+ ],
+ )
diff --git a/backend/tests/test_sprint223_governed_grb_refresh.py b/backend/tests/test_sprint223_governed_grb_refresh.py
new file mode 100644
index 00000000..65cf022a
--- /dev/null
+++ b/backend/tests/test_sprint223_governed_grb_refresh.py
@@ -0,0 +1,250 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from types import SimpleNamespace
+import uuid
+
+import pytest
+
+from app.core.errors import AppError
+from app.models import Dataset
+from app.services.grb_refresh_plan_service import GrbRefreshPlanService
+
+
+NOW = datetime(2026, 7, 16, 16, 0, tzinfo=timezone.utc)
+PROJECT_ID = uuid.uuid4()
+
+
+class _Query:
+ def __init__(self, rows: list[Dataset]) -> None:
+ self.rows = rows
+
+ def filter(self, *_args):
+ return self
+
+ def all(self) -> list[Dataset]:
+ return self.rows
+
+
+class _Db:
+ def __init__(self, rows: list[Dataset]) -> None:
+ self.rows = rows
+
+ def get(self, _model, identifier):
+ return SimpleNamespace(id=identifier)
+
+ def query(self, _model):
+ return _Query(self.rows)
+
+
+def _dataset(theme: str, version: str = "2026-07-14", count: int = 100) -> Dataset:
+ return Dataset(
+ id=uuid.uuid4(),
+ project_id=PROJECT_ID,
+ name=f"grb_{theme}.geojson",
+ dataset_type="vector",
+ source="operator_official_import",
+ source_name="grb",
+ dataset_role="reference",
+ reference_layer_name=theme,
+ source_version=version,
+ temporal_series_key=f"grb:{theme}:kempen-transport-region",
+ observed_at=datetime.fromisoformat(f"{version}T00:00:00+00:00"),
+ imported_at=NOW,
+ metadata_json={"feature_count": count},
+ size_bytes=1000,
+ status="ready",
+ )
+
+
+def _catalog(status: str = "available", version: str | None = "Toestand 2026-07-15"):
+ return SimpleNamespace(
+ items=[
+ SimpleNamespace(
+ source_name="grb",
+ status=status,
+ reachable=status == "available",
+ remote_version=version,
+ checked_at=NOW,
+ )
+ ]
+ )
+
+
+def test_refresh_plan_marks_all_older_immutable_snapshots_as_update_available(monkeypatch) -> None:
+ rows = [_dataset(theme, count=(index + 1) * 100) for index, theme in enumerate(("buildings", "roads", "water", "parcels"))]
+ monkeypatch.setattr(
+ "app.services.grb_refresh_plan_service.SourceCatalogProbeService.audit_project",
+ lambda *_args, **_kwargs: _catalog(),
+ )
+
+ plan = GrbRefreshPlanService.build(_Db(rows), PROJECT_ID, now=NOW)
+
+ assert plan.remote_edition_date.isoformat() == "2026-07-15"
+ assert plan.summary.update_available_count == 4
+ assert plan.summary.new_dataset_count_if_applied == 4
+ assert plan.summary.retained_dataset_count == 4
+ assert plan.summary.current_feature_count == 1000
+ assert plan.summary.current_size_bytes == 4000
+ assert all(item.status == "update_available" for item in plan.layers)
+ assert all(item.retained_after_refresh for item in plan.layers)
+ assert plan.automatic_import is False
+ assert plan.destructive_replacement is False
+
+
+@pytest.mark.parametrize(
+ ("rows", "catalog", "expected"),
+ [
+ ([_dataset("buildings", "2026-07-15")], _catalog(), "current"),
+ ([], _catalog(), "not_loaded"),
+ ([_dataset("buildings")], _catalog("unavailable"), "remote_unavailable"),
+ ([_dataset("buildings")], _catalog("available", "Onbekende toestand"), "review_required"),
+ ],
+)
+def test_refresh_plan_status_matrix(monkeypatch, rows, catalog, expected) -> None:
+ monkeypatch.setattr(
+ "app.services.grb_refresh_plan_service.SourceCatalogProbeService.audit_project",
+ lambda *_args, **_kwargs: catalog,
+ )
+ plan = GrbRefreshPlanService.build(_Db(rows), PROJECT_ID, now=NOW)
+ buildings = next(item for item in plan.layers if item.theme == "buildings")
+ assert buildings.status == expected
+
+
+def test_refresh_plan_rejects_an_unapproved_scope(monkeypatch) -> None:
+ monkeypatch.setattr(
+ "app.services.grb_refresh_plan_service.SourceCatalogProbeService.audit_project",
+ lambda *_args, **_kwargs: _catalog(),
+ )
+ with pytest.raises(AppError) as raised:
+ GrbRefreshPlanService.build(_Db([]), PROJECT_ID, scope="mol", now=NOW)
+ assert raised.value.code == "GRB_REFRESH_SCOPE_UNSUPPORTED"
+
+
+def test_refresh_plan_route_uses_the_canonical_envelope(monkeypatch) -> None:
+ from app.api.routes import datasets as dataset_routes
+
+ monkeypatch.setattr(
+ "app.services.grb_refresh_plan_service.SourceCatalogProbeService.audit_project",
+ lambda *_args, **_kwargs: _catalog(),
+ )
+ response = dataset_routes.plan_grb_dataset_refresh(
+ project_id=PROJECT_ID,
+ scope="kempen-transport-region",
+ refresh_catalog=False,
+ db=_Db([]),
+ )
+ assert list(response) == ["data"]
+ assert response["data"]["project_id"] == PROJECT_ID
+ assert response["data"]["automatic_import"] is False
+
+
+def _load_operator_module():
+ root = Path(__file__).resolve().parents[2]
+ scripts = root / "scripts"
+ if str(scripts) not in sys.path:
+ sys.path.insert(0, str(scripts))
+ spec = importlib.util.spec_from_file_location("manage_grb_refresh_s223", scripts / "manage_grb_refresh.py")
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_operator_requires_exact_edition_and_plan_hash() -> None:
+ module = _load_operator_module()
+ assert module.require_confirmed_edition({"remote_edition_date": "2026-07-15"}, "2026-07-15") == "2026-07-15"
+ with pytest.raises(RuntimeError, match="confirm-edition"):
+ module.require_confirmed_edition({"remote_edition_date": "2026-07-15"}, "2026-07-14")
+
+ payload = {"status": "staged", "layers": []}
+ first = module.canonical_plan_sha256(payload)
+ assert first == module.canonical_plan_sha256({**payload, "plan_sha256": first})
+ assert first != module.canonical_plan_sha256({"status": "staged", "layers": [{"theme": "roads"}]})
+
+
+def test_operator_validates_every_staged_artifact_and_partition(tmp_path) -> None:
+ module = _load_operator_module()
+ manifest_dir = tmp_path / "kempen-transport-region" / "buildings" / "2026-07-15"
+ partition_dir = manifest_dir / "partitions"
+ partition_dir.mkdir(parents=True)
+ artifact = manifest_dir / "buildings.geojson"
+ partition = partition_dir / "13025_mol.geojson"
+ artifact.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
+ partition.write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8")
+ manifest = {
+ "status": "complete",
+ "scope": "kempen-transport-region",
+ "theme": "buildings",
+ "observed_at": "2026-07-15",
+ "reference_truncated": False,
+ "member_count": 1,
+ "feature_count": 1,
+ "artifact_filename": artifact.name,
+ "artifact_sha256": module.sha256_file(artifact),
+ "artifact_size_bytes": artifact.stat().st_size,
+ "partitions": [{"filename": partition.name, "sha256": module.sha256_file(partition)}],
+ }
+ path = manifest_dir / "regional_buildings_manifest.json"
+ path.write_text(json.dumps(manifest), encoding="utf-8")
+
+ assert module.validate_manifest(
+ path,
+ output_root=tmp_path,
+ scope="kempen-transport-region",
+ theme="buildings",
+ edition="2026-07-15",
+ )["feature_count"] == 1
+
+ partition.write_text("changed", encoding="utf-8")
+ with pytest.raises(RuntimeError, match="partition checksum"):
+ module.validate_manifest(
+ path,
+ output_root=tmp_path,
+ scope="kempen-transport-region",
+ theme="buildings",
+ edition="2026-07-15",
+ )
+
+
+def test_operator_builds_only_allowlisted_local_subprocess_commands(tmp_path) -> None:
+ module = _load_operator_module()
+ args = SimpleNamespace(
+ scope="kempen-transport-region",
+ api_url="http://127.0.0.1:8000/api/v1",
+ output_root=tmp_path,
+ request_timeout=180,
+ api_timeout=180,
+ batch_size=1000,
+ page_limit=1000,
+ max_features_per_member=100000,
+ max_total_features=1500000,
+ )
+ commands = module.build_operator_commands(
+ args,
+ ["buildings", "roads", "water", "parcels"],
+ "2026-07-15",
+ fetch_only=True,
+ )
+ flattened = [argument for _label, command in commands for argument in command]
+ assert len(commands) == 2
+ assert "--fetch-only" in flattened
+ assert "provision_regional_grb_buildings.py" in " ".join(flattened)
+ assert "provision_regional_grb_context.py" in " ".join(flattened)
+ assert "--force" not in flattened
+
+
+def test_refresh_api_and_frontend_remain_explicit_only() -> None:
+ root = Path(__file__).resolve().parents[2]
+ service = (root / "backend" / "app" / "services" / "grb_refresh_plan_service.py").read_text(encoding="utf-8")
+ hook = (root / "frontend" / "src" / "hooks" / "useSourceFreshness.ts").read_text(encoding="utf-8")
+ operator = (root / "scripts" / "manage_grb_refresh.py").read_text(encoding="utf-8")
+ assert "DatasetService" not in service
+ assert "VectorFeatureService" not in service
+ assert "void probeCatalogs(" not in hook
+ assert 'choices=("plan", "stage", "apply")' in operator
+ assert "--confirm-plan-sha256" in operator
diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md
index 744c08dc..1b203cee 100644
--- a/docs/API_CONTRACTS.md
+++ b/docs/API_CONTRACTS.md
@@ -432,6 +432,30 @@ not fetch vector features, raster pixels or models, create jobs/datasets, write
to PostGIS or trigger an import. The normal `source-freshness` endpoint remains
local-only and never invokes this probe implicitly.
+### GET `/api/v1/projects/{project_id}/datasets/grb-refresh-plan`
+
+Builds a read-only refresh decision for the governed
+`kempen-transport-region` GRB snapshot series. Query parameter
+`refresh_catalog=true` explicitly bypasses the catalog cache. The response
+maps the official dated edition to `buildings`, `roads`, `water` and `parcels`
+and reports each latest local Dataset, source version, observation date,
+feature count, artifact size and refresh state.
+
+Layer state is `current`, `update_available`, `not_loaded`, `review_required`
+or `remote_unavailable`. The summary reports how many new immutable Datasets
+would be created and how many existing snapshots remain retained. The endpoint
+does not fetch GRB features, stage files, create a Job, write to PostGIS or
+start an operator process. Exact remote deltas remain unavailable until every
+municipality partition has been staged and validated.
+
+Regional execution uses `scripts/manage_grb_refresh.py` outside the request
+cycle. `stage` requires the exact official ISO edition, invokes the existing
+regional GRB operators with `--fetch-only`, validates all artifact/partition
+checksums and emits a SHA-256-bound plan. `apply` requires that exact plan hash,
+revalidates every staged byte and delegates persistence to DatasetService and
+VectorFeatureService. It creates new temporal snapshots and never deletes or
+overwrites an older Dataset.
+
### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}`
Return metadata.
diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md
index 3696182c..b57c4423 100644
--- a/docs/CODEX_EXECUTION_LOG.md
+++ b/docs/CODEX_EXECUTION_LOG.md
@@ -9339,3 +9339,36 @@ Next:
- Extend the same probe pattern only where another official source publishes a
stable machine-readable edition. Do not infer release versions from service
protocol versions, ETags or HTTP modification dates.
+
+## Sprint 223 - Governed regional GRB refresh (2026-07-16)
+
+Implemented:
+- Added a canonical read-only refresh plan that maps the official dated GRB
+ edition onto the immutable regional buildings, roads, water and parcel
+ temporal series.
+- Added explicit per-layer current/update/review states, existing Dataset,
+ feature/storage impact and non-destructive action guidance.
+- Added `manage_grb_refresh.py` with separate plan, fetch-only stage and
+ checksum-confirmed apply phases. Stage validates all retained artifacts and
+ municipality partitions; apply revalidates the exact staged plan before
+ calling the existing regional DatasetService-based operators.
+- Added a compact Status surface after the explicit official catalog check.
+
+Boundaries:
+- No migration, scheduler, arbitrary provider URL, browser-triggered heavy
+ import, direct vector-feature write or automatic Dataset replacement was
+ introduced.
+- Existing GRB snapshots remain immutable historical observations. Exact
+ remote deltas are reported only after full staging, never guessed from
+ capabilities metadata.
+
+Validation so far:
+- Backend compile and frontend typecheck passed.
+- Source-catalog plus governed-refresh focused suite passed: 20 tests.
+- Full `scripts/run_readiness_check.sh` passed: 779 backend tests, 110
+ documented route contracts, one Alembic head, frontend typecheck/build and
+ all packaged smoke checks.
+
+Next:
+- Run the full release gate, deploy to Tower, execute a live read-only plan and
+ then stage/apply only the exact confirmed official edition.
diff --git a/docs/DATABASE_IMPLEMENTATION_PLAN.md b/docs/DATABASE_IMPLEMENTATION_PLAN.md
index 89416396..7e7ae0c7 100644
--- a/docs/DATABASE_IMPLEMENTATION_PLAN.md
+++ b/docs/DATABASE_IMPLEMENTATION_PLAN.md
@@ -267,6 +267,13 @@ evidence audit. External catalogue checks and refresh jobs remain explicit
operator actions; they may never overwrite a fixed edition or scenario in
place.
+Regional GRB refreshes add no lifecycle table. A read-only plan compares the
+official dated edition with the latest Dataset in each governed temporal
+series. Staging is filesystem evidence only. Checksum-confirmed apply creates a
+new Dataset plus DatasetVersion and vector_features through the existing
+DatasetService/VectorFeatureService transaction; prior snapshots remain
+unchanged and queryable for temporal comparison.
+
## Geometry normalization
- User-drawn polygons arrive as EPSG:4326.
diff --git a/docs/TODO.md b/docs/TODO.md
index 08d26d53..edba95e2 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -40,6 +40,7 @@
- [x] Present municipal DHMV/VMM partitions as logical regional layers and analyse cross-boundary rectangles without a municipality prerequisite.
- [x] Add a read-only source freshness/version audit with explicit publication policies, local integrity checks, operator CLI and compact Status workspace surface.
- [x] Add explicit bounded GRB/orthophoto catalogue release probes using official capabilities and ISO metadata; keep every acquisition separate and operator-controlled.
+- [x] Add a governed regional GRB plan -> stage -> checksum-confirmed apply workflow that preserves every previous snapshot.
- [ ] Extend catalogue probes only to additional sources that publish a stable machine-readable edition contract; do not add background polling or infer releases from HTTP dates alone.
## Governed source expansion backlog
diff --git a/frontend/README.md b/frontend/README.md
index 0eb91901..85aa1a79 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -13,6 +13,13 @@ replaces source data. A separate `Officiële edities controleren` action is the
only trigger for bounded GRB/orthophoto catalog reads. It presents official and
local editions, layer-contract coverage and honest non-comparable version
markers without starting an import or background poll.
+After that explicit check, the same surface shows a compact GRB refresh plan
+for buildings, roads, water and parcels. It states whether each local regional
+snapshot is current, updateable or needs review and shows the current object
+count. The UI cannot start the million-feature import: staging and checksum-
+confirmed application remain an operator action so a browser request cannot
+silently replace regional reference data. Existing snapshots remain visible
+as historical observations.
The user-facing shell is task based: `Kaart`, `Bronnen`, `Kwaliteit`, `Beeldanalyse`, `Downloads`, `Status` and `Beheer`. Internal benchmark projects, raw dataset metadata, provider capabilities, model registry details and QA evidence remain accessible through labelled advanced disclosures instead of competing with the normal workflow.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9d01c1a9..ddbe41d9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -864,6 +864,9 @@ function App(): JSX.Element {
catalogLoading={sourceFreshness.catalogLoading}
catalogError={sourceFreshness.catalogError}
onProbeCatalogs={(force) => { void sourceFreshness.probeCatalogs(force) }}
+ grbRefreshPlan={sourceFreshness.grbRefreshPlan}
+ grbRefreshPlanLoading={sourceFreshness.grbRefreshPlanLoading}
+ grbRefreshPlanError={sourceFreshness.grbRefreshPlanError}
/>
{item.action_message}
diff --git a/frontend/src/components/status/SourceFreshnessPanel.tsx b/frontend/src/components/status/SourceFreshnessPanel.tsx
index 2e089ded..6694b920 100644
--- a/frontend/src/components/status/SourceFreshnessPanel.tsx
+++ b/frontend/src/components/status/SourceFreshnessPanel.tsx
@@ -1,4 +1,7 @@
import type {
+ GrbRefreshLayerPlan,
+ GrbRefreshLayerStatus,
+ GrbRefreshPlan,
SourceCatalogComparisonStatus,
SourceCatalogProbeItem,
SourceCatalogProbeReport,
@@ -17,6 +20,9 @@ interface SourceFreshnessPanelProps {
catalogLoading: boolean
catalogError: string | null
onProbeCatalogs: (force: boolean) => void
+ grbRefreshPlan: GrbRefreshPlan | null
+ grbRefreshPlanLoading: boolean
+ grbRefreshPlanError: string | null
}
function statusLabel(status: SourceFreshnessStatus): string {
@@ -46,6 +52,35 @@ function formatDate(value?: string | null): string {
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
}
+function formatCount(value?: number | null): string {
+ return value == null ? 'nog niet bekend' : value.toLocaleString('nl-BE')
+}
+
+function grbLayerStatusLabel(status: GrbRefreshLayerStatus): string {
+ if (status === 'current') return 'actueel'
+ if (status === 'update_available') return 'update beschikbaar'
+ if (status === 'not_loaded') return 'nog niet ingeladen'
+ if (status === 'remote_unavailable') return 'bron niet bereikbaar'
+ return 'beoordeling nodig'
+}
+
+function GrbRefreshLayerRow({ item, remoteEdition }: { item: GrbRefreshLayerPlan; remoteEdition?: string | null }): JSX.Element {
+ return (
+
Veilig vernieuwingsplan wordt opgebouwd...
: null} + {grbRefreshPlanError ?{grbRefreshPlanError}
: null} + {grbRefreshPlan ? ( ++ Een serveroperator controleert eerst exacte aantallen en checksums. Bestaande snapshots blijven altijd behouden. +
+