Validating Code List Values Against INSPIRE Registers Jump to heading

An INSPIRE code list is not a Python set. It is a resource in a registry, owned by an authority, identified by a URI, versioned, and amended on a schedule you do not control. Treating it as a set that happens to live in your codebase produces a validator that is confidently wrong the first time the register changes; fetching it live at validation time produces a validator whose verdict depends on the network and on whether the authority deployed something last night. This procedure takes the third path — pin, cache, validate offline, and review amendments as code — and it implements the register case of attribute domain and code list validation, inside Spatial Data Quality Validation & Geometry Integrity.

The steps map to configure (Steps 1–2, identify and sync), execute (Step 3, offline validation), validate (Step 4, hierarchy and deprecation policy), and log (Step 5, the amendment diff). The same pattern applies to any governed vocabulary — a national land-use classification, a utility asset taxonomy, an ISO 19115 code list — and the INSPIRE registry is simply the best-documented example of the shape.

Prerequisites checklist Jump to heading

Step 1: Identify the register resource and its version Jump to heading

An INSPIRE code list URI such as http://inspire.ec.europa.eu/codelist/HILUCSValue identifies the concept, and content negotiation yields a machine-readable representation of its members. What matters for reproducibility is the version identifier the registry publishes alongside them.

python
# sync/registers.py — requests >= 2.31 — Python 3.10+ (BUILD TIME ONLY)
import requests

TIMEOUT = 30


def fetch_register(uri: str) -> dict:
    """Fetch the machine-readable representation of a code list."""
    response = requests.get(
        uri + ".en.json",
        headers={"Accept": "application/json"},
        timeout=TIMEOUT,
    )
    response.raise_for_status()
    payload = response.json()
    # The registry reports the version of the register itself, not of each member.
    version = payload["register"]["versionInfo"]
    return {"uri": uri, "version": version, "raw": payload}

Two habits are worth adopting here. Always request a specific representation rather than relying on the default, so a registry that changes its default format does not silently change your cache’s shape. And always fail on a non-200: a sync job that writes an HTML error page into the cache produces a validator that rejects every value with a message nobody can read.

Step 2: Normalize into a committed cache with a content hash Jump to heading

python
# sync/registers.py — Python 3.10+ (BUILD TIME ONLY)
import hashlib
import json
from pathlib import Path


def normalize(payload: dict) -> dict:
    """Flatten the registry representation into the stable shape validation reads."""
    members = []
    for item in payload["raw"]["register"]["containeditems"]:
        value = item["value"]
        members.append({
            "id":       value["id"].rsplit("/", 1)[-1],   # the code as it appears in data
            "uri":      value["id"],
            "label":    value["label"]["text"],
            "status":   value["status"]["label"]["text"].lower(),   # valid | superseded | retired
            "parent":   (value.get("parent") or {}).get("id", "").rsplit("/", 1)[-1] or None,
        })
    members.sort(key=lambda m: m["id"])          # stable order → stable diff
    body = {
        "uri": payload["uri"],
        "version": payload["version"],
        "members": members,
    }
    body["content_hash"] = hashlib.sha256(
        json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
    ).hexdigest()
    return body


def write_cache(body: dict, path: Path) -> None:
    path.write_text(json.dumps(body, indent=2, sort_keys=True) + "\n", encoding="utf-8")

Sorting the members before writing is the detail that makes this workflow usable. An unsorted cache produces a 900-line diff every time the registry reorders its response, and a reviewer confronted with that diff will approve it without reading. Sorted, the diff for a real amendment is three lines, and a reviewer can see exactly which codes appeared and which were retired.

Step 3: Validate offline, asserting the pin first Jump to heading

python
# quality/registers.py — Python 3.10+ (RUNTIME — no network imports)
import json
from pathlib import Path


def load_pinned(spec: dict) -> tuple[frozenset[str], frozenset[str], dict[str, str | None]]:
    path = Path(spec["cache_path"])
    if not path.is_file():
        raise FileNotFoundError(
            f"{spec['name']}: register cache {path} is missing. Run the sync job; "
            "validation never fetches."
        )
    body = json.loads(path.read_text(encoding="utf-8"))

    if body["version"] != spec["register_version"]:
        raise ValueError(
            f"{spec['name']}: cache holds {body['version']}, manifest pins "
            f"{spec['register_version']} — refusing to validate."
        )
    if body["uri"] != spec["register_uri"]:
        raise ValueError(f"{spec['name']}: cache is for {body['uri']}, not {spec['register_uri']}")

    active = frozenset(m["id"] for m in body["members"] if m["status"] == "valid")
    retired = frozenset(m["id"] for m in body["members"] if m["status"] != "valid")
    parents = {m["id"]: m["parent"] for m in body["members"]}
    return active, retired, parents

The version assertion is the single most valuable line in this procedure. Without it, a sync job that failed last week leaves an older cache in place, the validation run passes against a stale vocabulary, and the conformance report says everything is fine — the exact failure mode the whole pinning strategy exists to prevent. With it, the mismatch is a hard stop naming both versions, and the fix is obvious to whoever reads the log.

Step 4: Decide hierarchy and deprecation explicitly Jump to heading

INSPIRE code lists are frequently hierarchical: 1_1_1 (permanent crops) is a narrower term under 1_1 (agriculture). Whether a dataset may use a narrower value than the specification names is a policy decision, and so is whether a retired code is a rejection or a warning during a migration window.

python
# quality/registers.py — Python 3.10+
def resolve(value: str, active: frozenset[str], retired: frozenset[str],
            parents: dict[str, str | None], spec: dict) -> tuple[bool, str]:
    if value in active:
        return True, ""
    if value in retired:
        if spec.get("allow_deprecated"):
            return True, "ATTR_DEPRECATED_CODE_ACCEPTED"
        return False, "ATTR_DEPRECATED_CODE"

    if spec.get("allow_narrower"):
        # Walk up the hierarchy: a narrower term is acceptable if an ancestor is active.
        seen: set[str] = set()
        cursor = parents.get(value)
        while cursor and cursor not in seen:
            if cursor in active:
                return True, "ATTR_NARROWER_TERM_ACCEPTED"
            seen.add(cursor)
            cursor = parents.get(cursor)

    return False, "ATTR_UNDEFINED_CODE"

The seen set is not defensive decoration. Registers do occasionally publish a cycle after an editorial mistake, and a naive parent walk turns that into an infinite loop inside a validation job that then has to be killed by hand.

The default for both flags is off. allow_narrower off means a dataset that uses 1_1_1 where the specification says 1_1 is flagged, which is usually what an INSPIRE conformance test would say too. allow_deprecated off means a retired code is a rejection; turning it on should always carry an expiry date recorded next to it in the manifest.

Step 5: Review amendments as a pull request, not as a surprise Jump to heading

yaml
# .github/workflows/sync-registers.yml
name: sync-inspire-registers
on:
  schedule:
    - cron: "0 4 * * 1"        # weekly, Monday 04:00 UTC
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.11"}
      - run: pip install "requests>=2.31"
      - run: python -m sync.registers --manifest attribute_domains.yaml --out registers/
      - name: Report what would change
        run: python -m sync.registers --impact --input data/latest_delivery.parquet
      - uses: peter-evans/create-pull-request@v6
        with:
          branch: chore/register-sync
          title: "Register sync: new code list version(s) available"
          body-path: registers/IMPACT.md

The --impact step is what turns this from a dependency bump into a decision with evidence. It counts, against the most recent delivery, how many features carry a code that the new version retires or removes — so the pull request says “adopting HILUCS 2026-02-02 would reject 1,412 features currently using 2_3” rather than merely “the register changed”. That number decides whether the upgrade ships with a crosswalk, with a migration window, or immediately.

Live Fetch Versus Pinned Cache The upper row shows the live-fetch anti-pattern: the validation run calls the registry directly, so its verdict changes when the registry changes or when the network fails, and two runs over the same data can disagree. The lower row shows the pinned pattern: a scheduled sync job fetches the registry, writes a committed cache file with a version and a content hash, and opens a pull request when the version changes; the validation run reads only the committed file and never touches the network, so the same input always produces the same verdict. ANTI-PATTERN — verdict depends on the network validation run per delivery live registry amended without notice fetch per run same data, different verdicts outage = every feature rejected PINNED — verdict depends only on the repository scheduled sync job weekly, build time only committed cache version + content hash validation run offline, asserts the pin writes reads a new version arrives as a reviewable pull request with an impact count

Verification Jump to heading

python
# pytest >= 7
def test_pinned_cache_matches_manifest():
    active, retired, parents = load_pinned(LAND_USE_SPEC)
    assert "1_1_1" in active
    assert len(active) == 62                    # the pinned version's member count

def test_stale_cache_refuses_to_validate(tmp_path):
    spec = dict(LAND_USE_SPEC, register_version="1999-01-01")
    with pytest.raises(ValueError, match="refusing to validate"):
        load_pinned(spec)

def test_validation_path_never_imports_requests():
    # Negative control: proves the runtime cannot reach the network.
    import sys
    import quality.registers          # noqa: F401
    assert "requests" not in sys.modules

The third test looks unusual and earns its place: it is a structural guarantee that no future edit reintroduces a live fetch into the validation path. A comment saying “do not fetch here” has never stopped anyone; an assertion that fails the build has.

Confirm the cache is current with a one-liner before a delivery run:

bash
# jq >= 1.6
jq -r '"\(.uri)  version=\(.version)  members=\(.members | length)"' registers/hilucs-2023-04-11.json
# http://inspire.ec.europa.eu/codelist/HILUCSValue  version=2023-04-11  members=62

Troubleshooting Jump to heading

Symptom Likely cause Fix
Every value rejected as undefined The cache stores full URIs while the data stores short codes, or vice versa Normalize both to the short code in normalize(); store the URI alongside for reporting
The weekly sync produces a huge diff with no real change The registry reordered its response, or the fetch captured a timestamp Sort members before writing and exclude any response field that varies per request
ValueError: refusing to validate in production The pin was bumped in the manifest but the cache file was not regenerated Regenerate through the sync job; never hand-edit the version field in the cache
Narrower terms rejected in one dataset and accepted in another allow_narrower set inconsistently across manifests Set it per dataset deliberately and record why; the INSPIRE specification for each theme states which level is expected
Deprecated codes reappear after a migration The source system was never updated and allow_deprecated has no expiry Add an expiry date to the flag and fail the build once it passes