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:
- Readable.
GEOM_VALID_001tells a reader something before they open the registry;E4172does 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.
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.
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
- Categorizing and Prioritizing Spatial Errors — severity, routing and aggregation around these codes
- Classifying Topology Errors by Severity — how the severity attribute is decided
- Spatial Data Quality Metrics and Reporting — the metrics that group findings by code and dimension