Best practices for spatial metadata catalogs

In a domain-driven geospatial data mesh, catalog ingestion failures rarely originate from storage capacity or network latency — they stem from unvalidated spatial metadata propagation. A single misaligned coordinate reference system (CRS) or inverted bounding box in a raster or vector asset cascades into cross-domain spatial join failures, directly violating product discoverability SLAs. This page is a focused operational procedure for making catalog registration deterministic: contract enforcement at the ingestion gate, idempotent normalization to EPSG:4326, structured diagnostic logging, and zero-downtime rollback. It sits under the Metadata Cataloging for Raster/Vector reference within the broader Geospatial Data Mesh Fundamentals reference set, and it assumes the ownership and SLO model established in Product Thinking for GIS Datasets. Get the validation gate and idempotency semantics right and a non-conforming asset is rejected before it can pollute the discovery layer.

The catalog ingestion gate sequence with reject branches and rollback path A raw raster or vector asset enters a left-to-right pipeline of four ordered gates: CRS normalization to EPSG:4326, WKT envelope computation with min/max coordinate ordering, JSON-Schema field validation, and an idempotent atomic commit into the federated catalog. Each of the first three gates has a reject branch — labelled crs_drift, degenerate_extent, and schema_miss — that drops the non-conforming asset into a quarantine queue, which notifies the domain steward. A separate 7-day immutable snapshot store feeds a dashed rollback path that restores the catalog inside a single PostgreSQL transaction. Raw asset raster · vector 1 · CRS normalize → EPSG:4326 allowlist check 2 · WKT envelope min/max ordering non-degenerate 3 · JSON-Schema required fields v2 contract 4 · Atomic commit idempotent mv no partial state Federated catalog discovery layer crs_drift degenerate_extent schema_miss Quarantine queue P3 reject event → domain steward webhook (producer run still succeeds) 7-day snapshot immutable s3:// rollback single PG txn

Prerequisites

Requirement Value / Assumption Notes
Geospatial toolchain GDAL/OGR >= 3.6 (ogrinfo, gdalwarp, gdalinfo, gdalsrsinfo) Vector- and raster-agnostic extent extraction
Runtime Python >= 3.10 with shapely and jsonschema WKT envelope + schema validation
Catalog store PostgreSQL/PostGIS with a spatial_catalog table Atomic, transactional registration
Orchestration Airflow DAG, Tekton Task, or Kubernetes CronJob Hosts the spatial-extent-validator worker
CRS contract Source EPSG in the allowlist; all entries normalized to EPSG:4326 EPSG:3857 retained for tile-aligned products
Snapshot store Immutable object store (s3://) with rolling 7-day retention Source of truth for rollback
Schema spatial-catalog-v2.schema.json mounted at /schemas/ Versioned producer–consumer contract
Access role catalog-ingest-admin (RBAC) Required to mutate the catalog and rotate snapshots
Environment CATALOG_DB, SNAPSHOT_URI, WORK_DIR Injected by the orchestrator, never hard-coded

Step-by-Step Implementation

Validation is enforced at the ingestion boundary, never post-hoc inside the discovery layer, so a malformed asset is shed before any consumer can resolve it. Each step below is verifiable with a diagnostic command before you proceed to the next.

1. Enforce the ingestion contract at the catalog gate

Traditional monolithic GIS architectures rely on centralized metadata silos where validation happens after registration. A federated mesh shifts ownership to domain teams, so catalog registration must reject non-conforming assets at the boundary. The spatial-extent-validator config below codifies the explicit domain boundaries required by Scoping Rules for Spatial Products: only assets with verifiable extents, authoritative CRS declarations, and explicit ownership tags pass the gate.

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: spatial-catalog-validator-config
  namespace: data-mesh-ingest
data:
  validation_policy.yaml: |
    crs_normalization:
      target_epsg: 4326
      fallback_strategy: reject
      allowed_source_epsgs: [3857, 32610, 32611, 4269, 4326]
    extent_validation:
      enforce_wkt_polygon: true
      max_degenerate_area_sqm: 0.0
      invert_bbox_on_negative: true
      min_coverage_threshold: 0.01
    schema_enforcement:
      required_fields: ["product_id", "domain_owner", "spatial_extent", "crs_authority"]
      json_schema_path: "/schemas/spatial-catalog-v2.schema.json"

Verify the config is mounted and parseable before the worker starts:

bash
kubectl -n data-mesh-ingest get configmap spatial-catalog-validator-config \
  -o jsonpath='{.data.validation_policy\.yaml}' | \
  python3 -c 'import sys,yaml; yaml.safe_load(sys.stdin); print("policy OK")'

2. Run the idempotent extraction & normalization pipeline

The ingestion worker computes a unified EPSG:4326 WKT envelope, validates it against the catalog schema, and commits atomically. The trap-based workspace teardown prevents resource leaks, and the min/max coordinate ordering guards against inverted bounding boxes — the single most common cause of empty cross-domain join results.

bash
#!/usr/bin/env bash
set -euo pipefail

# Idempotent workspace setup
WORK_DIR=$(mktemp -d /tmp/spatial-validate-XXXXXX)
trap 'rm -rf "$WORK_DIR"' EXIT

SOURCE_ASSET_PATH="${1:?Missing source asset path}"
OUTPUT_CATALOG_ENTRY="${2:?Missing output catalog entry path}"
SCHEMA_PATH="/schemas/spatial-catalog-v2.schema.json"

# 1. Extract raw spatial extent (vector/raster agnostic)
ogrinfo -json -al "${SOURCE_ASSET_PATH}" 2>/dev/null | \
  jq -r '.layers[0].geometryFields[0].extent' > "${WORK_DIR}/raw_extent.json" || \
  { echo "FATAL: Failed to parse OGR extent"; exit 1; }

# 2. Normalize CRS to EPSG:4326 and compute WKT envelope
#    For raster assets: reproject a copy in memory, then read back extent
gdalwarp -t_srs EPSG:4326 -of GTiff \
  "${SOURCE_ASSET_PATH}" /vsimem/normalized.tif 2>/dev/null

EXTENT_RAW=$(gdalinfo -json /vsimem/normalized.tif | \
  jq -r '.cornerCoordinates | "\(.upperLeft[0]),\(.lowerRight[1]),\(.lowerRight[0]),\(.upperLeft[1])"')

# Clean up virtual memory filesystem
rm -f /vsimem/normalized.tif 2>/dev/null || true

# 3. Compute WKT polygon with explicit coordinate ordering using shapely
python3 - "${EXTENT_RAW}" > "${WORK_DIR}/normalized_extent.wkt" <<'PYEOF'
import sys
from shapely.geometry import box

coords = [float(x) for x in sys.argv[1].split(',')]
# Enforce min/max ordering to prevent inverted bboxes
xmin, ymin = min(coords[0], coords[2]), min(coords[1], coords[3])
xmax, ymax = max(coords[0], coords[2]), max(coords[1], coords[3])
poly = box(xmin, ymin, xmax, ymax)
if poly.is_empty or poly.area == 0:
    sys.exit('ERROR: Degenerate spatial extent detected')
print(poly.wkt)
PYEOF

# 4. Schema validation using jsonschema
python3 -m jsonschema \
  --instance "${WORK_DIR}/normalized_extent.wkt" \
  "${SCHEMA_PATH}" > "${WORK_DIR}/validated_entry.json" 2>&1 || {
  echo "FATAL: Schema validation failed"
  cat "${WORK_DIR}/validated_entry.json"
  exit 1
}

# Atomic swap prevents partial catalog states
mv "${WORK_DIR}/validated_entry.json" "${OUTPUT_CATALOG_ENTRY}"
echo "SUCCESS: Catalog entry committed idempotently"

This sequence guarantees repeatable execution regardless of retry count. For the band-level raster metadata that must accompany the extent contract, align with the parent Metadata Cataloging for Raster/Vector reference.

Verify idempotency by running the worker twice and confirming the committed entry is byte-identical:

bash
./validate.sh asset.tif out_a.json && ./validate.sh asset.tif out_b.json && \
  diff -q out_a.json out_b.json && echo "idempotent: identical entry"

3. Wire structured diagnostic logging

When ingestion fails or downstream spatial queries return empty result sets, the failure vector must be isolatable from logs alone. Emit every pipeline log as structured JSON carrying trace_id, asset_hash, and validation_stage fields. Detect CRS drift by comparing the detected projection against the allowlist:

bash
gdalsrsinfo -o proj "${SOURCE_ASSET_PATH}"   # compare against allowed_source_epsgs

Verify that rejected assets are queryable by stage — this query surfaces every CRS reject in one pass:

bash
grep -E '"validation_stage":"crs_normalization","status":"reject"' /var/log/ingest/*.json | \
  jq -r '.asset_path + " -> " + .detected_crs + " (expected: EPSG:4326)"'

4. Configure zero-downtime rollback & state recovery

Catalog corruption from bad metadata must be reversible without service interruption. Maintain a rolling 7-day snapshot of the catalog index in an immutable object store and recover with a single database transaction. Versioning of catalog entries follows the discipline in Spatial Product Lifecycle Management: semantic versions where minor bumps reflect extent corrections and major bumps indicate CRS migrations.

bash
#!/usr/bin/env bash
set -euo pipefail
SNAPSHOT_URI="${1:?Missing snapshot URI}"
CATALOG_DB="${2:?Missing catalog database URI}"

# 1. Verify snapshot integrity
aws s3 cp "${SNAPSHOT_URI}/catalog-checksum.sha256" /tmp/checksum
sha256sum -c /tmp/checksum || { echo "FATAL: Snapshot checksum mismatch"; exit 1; }

# 2. Download snapshot to local temp file
aws s3 cp "${SNAPSHOT_URI}/catalog_snapshot.csv" /tmp/catalog_snapshot.csv

# 3. Atomic swap via database transaction
psql "${CATALOG_DB}" <<'SQL'
BEGIN;
  CREATE TEMP TABLE catalog_backup AS TABLE spatial_catalog;
  TRUNCATE TABLE spatial_catalog;
  \copy spatial_catalog FROM '/tmp/catalog_snapshot.csv' CSV HEADER
  UPDATE spatial_catalog SET last_rollback_ts = NOW();
COMMIT;
SQL

# 4. Invalidate downstream cache
curl -sf -X POST http://mesh-cache.internal/v1/invalidate \
  -H "Content-Type: application/json" \
  -d '{"scope":"spatial_catalog"}'
echo "ROLLBACK_COMPLETE: Catalog restored to snapshot state"

The transaction guarantees consumers never observe a partial catalog state. Verify the restore by confirming row counts match the snapshot header:

bash
psql "${CATALOG_DB}" -tAc 'SELECT count(*) FROM spatial_catalog' && \
  tail -n +2 /tmp/catalog_snapshot.csv | wc -l

Post-rollback, trigger a full re-validation sweep through Step 2 to quarantine any asset that failed the original ingestion but was cached by consumers.

Configuration Reference

Parameter Scope Default / Example Effect
crs_normalization.target_epsg CRS 4326 Canonical projection for every registered extent
crs_normalization.fallback_strategy CRS reject Action when source CRS is outside the allowlist
crs_normalization.allowed_source_epsgs CRS [3857, 32610, 32611, 4269, 4326] Enterprise-approved source projections
extent_validation.enforce_wkt_polygon Geometry true Requires a closed polygon envelope, not a raw bbox
extent_validation.max_degenerate_area_sqm Geometry 0.0 Rejects zero-area lines/points that break indexing
extent_validation.invert_bbox_on_negative Geometry true Auto-corrects xmax < xmin ordering
extent_validation.min_coverage_threshold Geometry 0.01 Minimum fractional coverage to register
schema_enforcement.required_fields Schema product_id, domain_owner, spatial_extent, crs_authority Fields rejected if absent
schema_enforcement.json_schema_path Schema /schemas/spatial-catalog-v2.schema.json Versioned contract path
boundary_priority Governance integer Resolves overlapping cross-domain extents during joins
is_legacy Lifecycle false Flags a deprecated extent within its 90-day queryable window

Common Failure Modes & Fixes

Empty cross-domain join despite valid-looking assets. Symptom: a spatial join returns zero rows even though both products registered successfully. Root cause: an inverted bounding box (xmax < xmin) slipped through because invert_bbox_on_negative was disabled. Fix: set invert_bbox_on_negative: true and re-run Step 2; the shapely min/max ordering normalizes the envelope on commit.

CRS_DRIFT_DETECTED on a previously-passing producer. Symptom: assets from one domain begin rejecting at the crs_normalization stage. Root cause: the producer changed its source projection to an EPSG absent from allowed_source_epsgs. Fix: either reproject upstream to an allowlisted CRS, or — if the new projection is enterprise-approved — add it to the allowlist and bump schema_version; never silently widen the allowlist without a contract review.

Degenerate extent halts the pipeline. Symptom: ERROR: Degenerate spatial extent detected aborts ingestion. Root cause: the source geometry is a zero-area point or collapsed line, which breaks R-tree indexing. Fix: inspect with ogrinfo -al -so asset.gpkg; buffer the geometry upstream or exclude it from the product extent — do not lower max_degenerate_area_sqm to force it through.

Forked product_id after a retry storm. Symptom: two catalog rows share one logical product. Root cause: a non-idempotent commit path created duplicates under retry. Fix: enforce the atomic mv swap from Step 2, dedupe on product_id, and bump schema_version so the dedup is auditable.

Consumers serve stale geometry after a rollback. Symptom: clients return pre-rollback extents. Root cause: the downstream cache was not invalidated. Fix: confirm the POST /v1/invalidate call in Step 4 returned 2xx; re-issue with scope: spatial_catalog and verify the cache cleared before re-opening the discovery layer.

FAQ

Why normalize every extent to EPSG:4326 instead of storing the native CRS?

A federated catalog is the single point where products from many domains are joined, and a join is only valid when every operand shares one coordinate space. Normalizing to EPSG:4326 at ingestion makes the catalog’s geometry comparable by construction, so cross-domain spatial joins never silently mismatch on projection. Tile-aligned products still retain EPSG:3857 in their native band metadata, but the catalog envelope used for discovery is always the canonical EPSG:4326 WKT polygon computed in Step 2.

How do I reject a malformed asset without breaking the producing domain’s pipeline?

Keep fallback_strategy: reject at the gate but emit a structured P3 reject event to the domain steward’s webhook rather than failing silently. The producer’s run still completes; only the single non-conforming asset is quarantined. This preserves domain autonomy — the boundary owner fixes the extent or CRS and re-submits — while guaranteeing the discovery layer never sees an unvalidated record.

What makes the rollback genuinely zero-downtime?

The restore runs inside a single PostgreSQL transaction: TRUNCATE and \copy are wrapped in BEGIN/COMMIT, so concurrent readers either see the complete pre-rollback table or the complete restored table — never a partial state. Cache invalidation is the final step, after the transaction commits, so consumers cut over atomically. No discovery query is ever served from an in-flight catalog.

How long should deprecated extents stay queryable?

Retain them for 90 days after deprecation with is_legacy: true in the metadata payload. This window is a compliance requirement, not a convenience: it lets audit and rollback resolve historical lineage and gives consumers time to migrate off a corrected or migrated extent. Major version bumps (CRS migrations, topology restructuring) reset the clock; minor extent corrections do not.

How do I resolve two domains claiming overlapping extents?

Attach a boundary_priority field to each registered extent and let the higher priority win during cross-domain join resolution. The priority is assigned per the ownership map in Spatial Domain Boundary Design, so overlap is resolved by governance decision rather than by ingestion order or by whichever query ran last.