Validation Log Design for Compliance Pipelines
A validation log is the evidentiary spine of any automated compliance run: it records what data entered each stage, which rule version judged it, and whether the parcel passed or failed. This module sits inside Compliance Reporting & Audit Trail Generation and focuses on the schema, retention, and tamper-resistance decisions that let a reviewer reconstruct any verdict months later. When a zoning determination is challenged at a public hearing, the log — not the map — is what defends the decision.
Prerequisites
Before designing a logging layer, confirm the surrounding pipeline exposes the metadata the log needs to capture:
- A pipeline that processes parcels in discrete, identifiable stages (ingest, reproject, evaluate, report) so each stage can emit a record.
geopandas1.0+ andpyproj3.4+ available, so every record can readgdf.crsand the active transformation pipeline.- A rule store that exposes a stable version identifier per evaluation — ideally a Git commit hash or semantic version, as described in Provenance & Lineage Tracking.
- Write access to durable, append-only storage (object storage with versioning, or a WORM-configured bucket) rather than a mutable local file.
- Agreement with legal or records staff on a retention window — many jurisdictions require compliance evidence to survive 7–10 years.
Core Workflow
A defensible log is not a stream of free-text print() calls. It is a structured event per stage, written in a fixed schema so that queries and integrity checks stay deterministic. The workflow below captures the minimum viable record at each transition.
- Capture stage context. At the top of every stage, record the stage name, a correlation id shared across the whole batch, input feature counts, and the source layer references. This is what lets a reviewer answer “which file produced this verdict?”
- Stamp the coordinate system and rule version. Distance and area verdicts are meaningless without the CRS they were measured in. Record the active EPSG code alongside the rule version hash that governed the check.
- Record outcomes with counts. Log geometry counts before and after each operation, plus the pass/fail tally. A drop from 12,400 to 12,050 features between ingest and evaluation is a red flag the log must surface.
- Serialize to an append-only sink. Emit one structured record per stage to storage that cannot be rewritten in place.
- Chain records by hash so any post-hoc edit breaks the chain and is detectable.
The following record builder produces one canonical log entry per stage. It reads the CRS directly from the GeoDataFrame so the logged value can never drift from the data that was actually processed.
import hashlib
import json
from datetime import datetime, timezone
import geopandas as gpd
def build_stage_record(stage: str, batch_id: str, gdf: gpd.GeoDataFrame,
rule_version: str, passed: int, failed: int) -> dict:
"""Assemble one canonical validation-log entry for a pipeline stage."""
record = {
"batch_id": batch_id, # correlation id shared across the run
"stage": stage, # e.g. "reproject" or "setback_eval"
"ts": datetime.now(timezone.utc).isoformat(),
"epsg": gdf.crs.to_epsg() if gdf.crs else None, # measured CRS, never assumed
"rule_version": rule_version, # git hash or semver of the rule set
"feature_count": int(len(gdf)),
"null_geometry_count": int(gdf.geometry.isna().sum()),
"passed": int(passed),
"failed": int(failed),
}
# Deterministic digest so the record can be chained and verified later.
payload = json.dumps(record, sort_keys=True).encode("utf-8")
record["record_sha256"] = hashlib.sha256(payload).hexdigest()
return record
The precise on-disk encoding of these records — the JSON formatter, the handler configuration, and how correlation ids propagate through worker pools — is covered in depth in Structured JSON Logging for Geospatial Pipelines.
Implementation Patterns
Two patterns dominate production logging: inline emission and decorator-wrapped emission. Inline emission (calling build_stage_record explicitly) gives fine-grained control and is easiest to audit, but clutters business logic. A decorator centralizes the concern and guarantees no stage is silently skipped.
import functools
import logging
audit_log = logging.getLogger("compliance.audit")
def logged_stage(stage: str):
"""Wrap a stage function so its input CRS and counts are always recorded."""
def decorator(fn):
@functools.wraps(fn)
def wrapper(gdf, *, batch_id, rule_version, **kw):
before = len(gdf)
result = fn(gdf, batch_id=batch_id, rule_version=rule_version, **kw)
rec = build_stage_record(
stage, batch_id, result,
rule_version, passed=int((result["status"] == "PASS").sum()),
failed=int((result["status"] == "FAIL").sum()),
)
rec["input_count"] = before # surface silent feature loss between stages
audit_log.info(json.dumps(rec, sort_keys=True))
return result
return wrapper
return decorator
Prefer the decorator pattern for pipelines with many stages: it makes “every stage is logged” a structural guarantee rather than a code-review hope. Keep the emitted payload flat and JSON-serializable so downstream queries against the log store stay vectorized and index-friendly.
Edge Cases & Geometry Repair
Logging code frequently crashes on exactly the malformed data it is supposed to record. Guard against these before serialization:
- Undefined CRS.
gdf.crsreturnsNonefor layers loaded without projection metadata. Never coerce it to a default — log the null explicitly so the gap is visible, and raise upstream before any distance operation runs. - Null and empty geometries. Count them separately.
gdf.geometry.isna()catches missing rows;gdf.geometry.is_emptycatches valid-but-empty polygons that a naivelen()would still count as present. - Invalid topology. If a stage repairs geometry with
make_valid(), the log must record both the pre-repair invalid count and the post-repair count, so the transformation is auditable rather than invisible.
from shapely import make_valid
def log_repair_delta(gdf: gpd.GeoDataFrame) -> dict:
"""Record how many geometries were invalid before repair, for the audit trail."""
invalid_before = int((~gdf.geometry.is_valid).sum())
gdf = gdf.copy()
gdf["geometry"] = gdf.geometry.apply(make_valid) # repair self-intersections, slivers
invalid_after = int((~gdf.geometry.is_valid).sum())
return {"invalid_before": invalid_before, "invalid_after": invalid_after}
Audit Logging & Provenance
The log itself is the audit artifact, so its design goals are integrity and reconstructability. Three properties make a validation log defensible. First, completeness: every stage emits a record, and every record names its input layers, the CRS in force, the geometry counts, and the versioned rule reference that produced the verdict. Second, tamper-resistance: records are appended to WORM or object-versioned storage and chained by SHA-256 so a silent edit is detectable. Third, reproducibility: given the same inputs, CRS, and rule version, re-running the pipeline must reproduce identical verdicts — the log’s rule_version and epsg fields are what let an auditor prove that.
Two fields deserve special care. The CRS provenance — source EPSG, target EPSG, and the exact pyproj transformation pipeline — is subtle enough to warrant its own treatment in Capturing CRS Provenance in Validation Logs. The rule version field should trace back to a version-controlled source, which connects the log to broader Provenance & Lineage Tracking practices across the ETL chain.
Troubleshooting
- Counts drift between stages with no explanation. A spatial join dropped unmatched rows or a reprojection silently discarded null geometries. Log input and output counts on every stage so the drop is attributable rather than mysterious.
- CRS field logged as null on verdicts that used distances. The layer lost its projection metadata during an intermediate write (Shapefiles and some GeoJSON round-trips do this). Assert
gdf.crs is not Noneimmediately after every read, and fail loudly. - Hash chain fails verification. A record was re-serialized with a different key order. Always serialize with
sort_keys=Truebefore hashing so the digest is canonical and platform-independent. - Log volume overwhelms storage. Per-feature logging at municipal scale is expensive. Log per-batch aggregates by default and reserve per-feature records for failures and quarantined geometries.
- Retention window unclear. Timestamps are recorded but no lifecycle policy exists. Attach an object-storage lifecycle rule that matches the legally mandated retention period rather than relying on manual cleanup.
Conclusion
A validation log turns an opaque automated verdict into a defensible, reproducible record. By fixing a canonical per-stage schema, stamping every entry with its CRS and versioned rule reference, and writing to append-only storage chained by hash, compliance teams gain an audit trail that withstands legal scrutiny. Build the encoding details with Structured JSON Logging for Geospatial Pipelines, harden the coordinate-system record with Capturing CRS Provenance in Validation Logs, and situate the whole effort within Compliance Reporting & Audit Trail Generation.