Designing an Error Code Taxonomy for Spatial Defects

Every validation pipeline invents error identifiers, usually twice: once informally as rule function names, and again later when somebody needs to build a dashboard and discovers that the same defect is called three different things in three parts of the system. A code taxonomy is the small piece of design that avoids that — a stable identifier per defect class, a registry holding everything else about it, and an enforcement step that stops unregistered codes reaching production. This guide sets one up, extending the classification model in Categorizing and Prioritizing Spatial Errors.

Prerequisites

  • A list of the defects your pipeline currently reports — extracted from the code, not from documentation. This is the input to the taxonomy and it is usually longer and messier than expected.
  • The severity model already decided: blocker, warning, informational, as used throughout this section.
  • The ISO 19157 quality dimensions if you report conformance, so codes can be grouped by dimension for the metadata described in Aligning Spatial Metadata with ISO 19157.
  • A repository for the registry, versioned alongside the rules.

Step-by-Step Procedure

Step 1 — Choose a structure and commit to it

GEOM_VALID_001
│    │     └── sequence within the family, zero-padded, never reused
│    └──────── defect family within the domain
└───────────── domain namespace

Four properties make a code scheme work:

What a code identifies, and what the registry holdsTwo-part stack. The code itself carries only the namespace, family and sequence — a stable identifier. Everything mutable — name, severity, dimension, repairability, consequence text and status — lives in the registry beside it.The code: GEOM_VALID_001namespace · family · sequence — never changes meaningstable foreverRegistry: name and descriptionhuman-readable, translatable, freely editablemutableRegistry: severity and dimensionrouting and reporting — revised without breaking historymutableRegistry: consequence textwhat the executive report rendersmutableRegistry: status and successoractive or deprecated, with a migration targetmutableEncoding severity or team into the code is the mistake that forces either a wrong code or a code change, and both break history.
One immutable identifier, five mutable attributes — that split is the whole design.
  • Readable. GEOM_VALID_001 tells a reader something before they open the registry; E4172 does not.
  • Sortable and greppable. A prefix search finds every geometry validity code across logs, tickets and dashboards.
  • Stable. The code never changes meaning, even when the rule implementation, its name or its severity does.
  • Extensible. Adding a family or a member does not renumber anything.

A working namespace set for spatial validation:

Namespace Covers Examples
GEOM Single-geometry validity and structure GEOM_VALID_001, GEOM_TYPE_001, GEOM_WIND_001
TOPO Relationships between features TOPO_OVERLAP_001, TOPO_GAP_001, TOPO_CONTAIN_001
CRS Reference systems and precision CRS_MISSING_001, CRS_MISMATCH_001, CRS_PRECISION_001
ATTR Attribute contract ATTR_NULL_001, ATTR_DOMAIN_001, ATTR_RANGE_001
TEMP Temporal quality TEMP_STALE_001, TEMP_FUTURE_001
FMT File and format level FMT_SCHEMA_001, FMT_ENCODING_001
RAST Raster and elevation RAST_ALIGN_001, RAST_VOID_001
PROC Pipeline problems, not data problems PROC_TIMEOUT_001, PROC_SOURCE_UNAVAILABLE_001

The PROC namespace matters more than it looks. Mixing pipeline failures with data findings is the reason so many quality dashboards show a defect spike when the real event was a broken mount, and separating them at the identifier level makes that distinction impossible to lose.

Step 2 — Enumerate from real findings, not from theory

# taxonomy/step2_enumerate.py
import pandas as pd


def defect_inventory(findings: pd.DataFrame) -> pd.DataFrame:
    """What the pipeline actually reports today, by frequency."""
    return (findings.groupby(["rule_name", "message_template"])
            .agg(occurrences=("feature_id", "size"),
                 layers=("layer_id", lambda s: sorted(set(s))),
                 first_seen=("run_date", "min"),
                 last_seen=("run_date", "max"))
            .sort_values("occurrences", ascending=False)
            .reset_index())


def merge_candidates(inventory: pd.DataFrame) -> list[tuple]:
    """Rows that probably deserve one code, not two — same fix, same owner."""
    hints = [
        ("self-intersection", "ring self-intersection"),   # both → ST_MakeValid
        ("null geometry", "empty geometry"),               # both → reject
    ]
    out = []
    for a, b in hints:
        rows = inventory[inventory["message_template"].str.contains(a, case=False)
                         | inventory["message_template"].str.contains(b, case=False)]
        if len(rows) > 1:
            out.append((a, b, rows["rule_name"].tolist()))
    return out

Verification: the inventory almost always reveals two things — several distinct defect classes reported under one generic rule, and one defect class reported by three rules with different wording. Both need resolving before codes are assigned, because the codes will otherwise inherit the confusion permanently.

Step 3 — Register each code with everything it needs

# registry/error_codes.yaml
version: "2026.08"
codes:
  - code: GEOM_VALID_001
    name: "Geometry fails OGC validity"
    family: geometry
    dimension: logical_consistency
    severity: blocker
    auto_repairable: true
    repair: "ST_MakeValid, with an area-loss guard"
    consequence: "Area calculations refuse to run; the feature is excluded from reports"
    introduced: "2024-03-01"
    status: active
    example_message: "Self-intersection[404732.5 6789234.1]"

  - code: TOPO_OVERLAP_001
    name: "Parcels overlap"
    family: topology
    dimension: logical_consistency
    severity: blocker
    auto_repairable: false
    repair: "Steward resolves which boundary is authoritative"
    consequence: "Two owners are billed for the same land"
    introduced: "2024-03-01"
    status: active
    example_message: "overlaps P-4417 by 12.40 m2"

  - code: TOPO_SLIVER_001
    name: "Sliver polygon below tolerance"
    family: topology
    dimension: logical_consistency
    severity: warning
    auto_repairable: true
    repair: "Dissolve into the larger neighbour"
    consequence: "Minor area misattribution; clutters overlay results"
    introduced: "2024-06-14"
    status: active

  - code: GEOM_BUFFER_001
    name: "Buffer-based validity workaround"
    family: geometry
    dimension: logical_consistency
    severity: warning
    status: deprecated
    deprecated_on: "2025-11-02"
    superseded_by: GEOM_VALID_001
    note: "Split into GEOM_VALID_001 when buffer(0) was removed from the repair path"

Verification: every field earns its place. consequence is what the executive report renders, per Reporting Data Quality to Non-Technical Stakeholders; auto_repairable and repair drive the routing; dimension feeds the scorecard. A registry with only code and description is a glossary, not a registry.

Step 4 — Enforce the registry in code

# taxonomy/step4_enforce.py
from dataclasses import dataclass
from functools import lru_cache

import yaml


@dataclass(frozen=True)
class Registry:
    version: str
    codes: dict

    def require(self, code: str) -> dict:
        entry = self.codes.get(code)
        if entry is None:
            raise KeyError(f"error code {code!r} is not in registry {self.version}")
        if entry["status"] == "deprecated":
            raise ValueError(
                f"{code} was deprecated on {entry['deprecated_on']}; "
                f"use {entry.get('superseded_by', 'a current code')}")
        return entry


@lru_cache(maxsize=1)
def load_registry(path: str = "registry/error_codes.yaml") -> Registry:
    doc = yaml.safe_load(open(path, encoding="utf-8"))
    return Registry(version=doc["version"], codes={c["code"]: c for c in doc["codes"]})


def finding(code: str, feature_id: str, message: str, **extra) -> dict:
    """The only way a rule is allowed to produce a finding."""
    entry = load_registry().require(code)
    return {
        "rule": code,
        "feature_id": feature_id,
        "severity": entry["severity"],
        "dimension": entry["dimension"],
        "message": message,
        "registry_version": load_registry().version,
        **extra,
    }
# tests/test_registry_enforcement.py
import pytest

from taxonomy.step4_enforce import finding


def test_unregistered_code_is_rejected():
    with pytest.raises(KeyError):
        finding("MADE_UP_999", "F001", "something happened")


def test_deprecated_code_is_rejected():
    with pytest.raises(ValueError, match="deprecated"):
        finding("GEOM_BUFFER_001", "F001", "legacy path")


def test_severity_comes_from_the_registry_not_the_caller():
    f = finding("TOPO_SLIVER_001", "F001", "0.04 m2 sliver")
    assert f["severity"] == "warning"

Verification: the third test is the important one. Severity supplied by the caller drifts — one rule says blocker, another says warning, for the same code. Reading it from the registry makes that impossible.

Step 5 — Deprecate without breaking history

# taxonomy/step5_deprecate.py
def migration_map(registry) -> dict:
    """Old code -> current code, for reading historical findings."""
    return {code: entry["superseded_by"]
            for code, entry in registry.codes.items()
            if entry["status"] == "deprecated" and entry.get("superseded_by")}


def normalise_historical(findings, registry) -> list[dict]:
    """Present historical findings under their current codes, keeping the original."""
    mapping = migration_map(registry)
    out = []
    for f in findings:
        current = mapping.get(f["rule"], f["rule"])
        out.append({**f, "rule": current,
                    "original_rule": f["rule"] if current != f["rule"] else None})
    return out

Verification: a trend chart built over normalised historical findings shows continuity across a code split; one built over raw codes shows a cliff. Keeping original_rule means the raw record is still recoverable, which matters for audit.

Interpreting Results

Registry signal What it indicates Action
A code with no findings for six months Rule disabled, or defect class eliminated Confirm which; a silently disabled rule is a coverage gap
A code that dominates every run Either a real systemic defect, or too coarse a code If the fixes differ case by case, split it
Frequent additions in one family That area of the data is poorly understood Expected during maturity; review after
PROC codes appearing in quality reports Pipeline failures leaking into data metrics Filter by namespace in the reporting layer
Deprecated codes still emitted An old rule path is still live The enforcement in Step 4 should make this impossible
Two codes always co-occurring They are one defect reported twice Merge, with a deprecation

The last row is the most common taxonomy defect in practice. When two codes always appear together on the same feature, they are not two findings — they are one defect and a consequence of it, and reporting both doubles every count for no additional information.

Namespaces and what belongs in eachGrid of six namespaces against three properties: the layer of the stack each covers, whether findings in it are data defects or pipeline problems, and who normally owns them.coverskindownerGEOMsingle featuredataanalystTOPOfeature pairsdatastewardCRSreference systemsdataanalystATTRattribute contractdatastewardFMTfile and schemadataplatformPROCthe pipeline itselfnot dataplatformThe PROC namespace exists so pipeline failures never appear in a data-quality metric.Mixing pipeline failures with data findings is why so many quality dashboards show a defect spike when the real event was a broken mount.
Six namespaces, and the last one exists purely to keep the metrics honest.

Gotchas & Edge Cases

Do not encode severity, layer or team in the code. All three change. The code identifies the defect class and nothing else; everything mutable lives in the registry.

A code being split, without breaking historyTimeline of a code lifecycle: a broad code is introduced, production shows it covers two distinct defects, a new code is allocated for the second, the old code is deprecated with a successor mapping, and historical findings are presented under the current code.Code introducedGEOM_BUFFER_001, broad2024-03Two defects foundone code, two different fixes2025-09New code allocatedGEOM_VALID_001 added2025-11Old code deprecatedsuccessor recorded, never reused2025-11History normalisedtrend charts stay continuous2026-08Reuse is what breaks history. A retired number costs nothing to leave retired, and retrospective ambiguity is expensive forever.
Codes are allocated once and retired once; the successor mapping is what keeps a trend chart readable across the split.

Zero-pad the sequence. GEOM_VALID_1 and GEOM_VALID_10 sort adjacently and confusingly; three digits is enough for any real rule set and sorts correctly everywhere.

Resist a hierarchical numbering scheme. 1.4.2.7 looks organised and breaks the first time a defect belongs in two places. A flat namespace with a family prefix is easier to maintain and easier to grep.

One code per rule is not the goal. Two rules — a fast pre-filter and an exact check — can legitimately emit the same code, because the defect class is the same. Conversely, one rule that detects three distinct defect classes should emit three codes.

The registry must be loadable at runtime, not just at build time. A dashboard rendering consequence text needs the same registry the pipeline used, which means publishing it as an artefact rather than embedding it in the validator image.

Translation belongs to the name, never the code. A multilingual deployment translates name and consequence; the code stays ASCII and unchanged, which is precisely why it is a code.

When to Escalate

  • Two teams using different codes for the same defect needs a single owner for the registry. Without one, the taxonomy forks and every cross-team report becomes a reconciliation exercise.
  • Pressure to reuse a retired code should always be refused; the cost of a new number is zero and the cost of ambiguous history is not.
  • A code whose severity is disputed is a policy question for the data steward, resolved in the registry rather than in the rule implementation, following Defining Spatial Data Quality Policies.
  • A defect class nobody can write a consequence for is a candidate for deletion. If no downstream process is harmed by it, the rule is generating work rather than preventing it.

Frequently Asked Questions

Why not just use descriptive rule names instead of codes?

Names change. Somebody renames overlap_check to parcel_overlap_rule during a refactor and every dashboard, ticket, saved query and historical report referencing the old name breaks silently. A code is a stable identifier that survives renaming, translation and reorganisation, and the human-readable name lives in the registry next to it.

How granular should codes be?

One code per defect class that has a distinct response. If two situations are always fixed the same way by the same person, they are one code. If a self-intersection is auto-repaired and a hole outside its shell needs a steward, they are two codes even though both are validity failures. Granularity follows remediation, not taxonomy.

Should severity be part of the code?

No. Severity changes — a warning becomes a blocker when a downstream system starts depending on the data — and encoding it in the identifier means either a wrong code or a code change. Keep severity as a registry attribute so it can be revised without invalidating history.

Can a retired code be reused for a new defect?

Never. Historical findings, tickets and reports still reference it, and reuse silently changes what those records mean. Retire it in the registry with a status and a successor, and allocate a new number. Numbers are cheap; retrospective ambiguity is not.


Related

Back to Categorizing and Prioritizing Spatial Errors