Publishing a Spatial Product from One Manifest
The measure of a self-serve platform is how much a domain has to know to publish. This guide walks the whole path from a single declaration to a discoverable, routable, monitored product — reprojection, quality gate, tiling, catalog registration and port publication — showing what each step consumes from the manifest and what it refuses to infer. It is the runtime path through Self-Serve Platform Capabilities for Spatial Teams within Geospatial Data Mesh Fundamentals, running on the infrastructure from Provisioning Spatial Infrastructure with Terraform.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | Platform CLI, python3 ≥ 3.11, psql ≥ 14, ogr2ogr (GDAL ≥ 3.6) |
All invoked by the platform, not by hand |
| Provisioning | terraform apply already reconciled |
Publishing does not provision |
| Manifest | product.yaml with source.crs and source.transformation declared |
Both fail closed if absent |
| CRS convention | Storage EPSG:4326; tiles EPSG:3857 via WebMercatorQuad |
Declared, never inferred |
| Access roles | domain-owner |
No platform-team involvement in a publish |
| Environment | PARTITION, SOURCE_URI |
The partition is the unit of work |
Step-by-Step Implementation
1. Resolve the partition and fail closed on an undeclared CRS
The partition — an extent plus an observation window — is what makes a publish re-runnable. The CRS is what makes it correct.
# publish.py (step 1) — resolve the unit of work and refuse to guess a CRS.
import hashlib
import sys
import yaml
def partition_key(manifest: dict, partition: str) -> str:
"""Deterministic identity for this unit of work. Re-publishing the same
partition overwrites exactly it and nothing else."""
spec = manifest["spec"]
parts = [
manifest["metadata"]["domain"], manifest["metadata"]["name"],
partition, spec["storage"]["crs"], spec["source"]["transformation"],
]
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:20]
def resolve_source_crs(manifest: dict) -> str:
"""The declared CRS is authoritative. There is no inference path: a source
with no declared CRS is quarantined, because guessing produces perfectly
valid geometry in the wrong place and nothing downstream will notice."""
crs = manifest["spec"]["source"].get("crs")
if not crs or crs == "REPLACE_ME":
raise SystemExit("source.crs is not declared — quarantining, not guessing")
return crs
Verify the key is stable and the CRS gate bites:
python3 -c "
import yaml, publish
m = yaml.safe_load(open('product.yaml'))
print(publish.partition_key(m, '2026-08'), publish.resolve_source_crs(m))"
2. Reproject with the pinned transformation
The transformation comes from the manifest, not from PROJ’s best-available selection, so the same partition reprojects identically on every worker.
# Reproject to the canonical storage CRS using the declared pipeline.
# -s_srs and -t_srs alone let PROJ choose; --ct pins the transformation explicitly.
ogr2ogr \
-f PostgreSQL "PG:${PG_DSN}" "${SOURCE_URI}" \
-nln "${PRODUCT}_staging" \
-s_srs "EPSG:32633" -t_srs "EPSG:4326" \
-ct "+proj=pipeline +step +inv +proj=utm +zone=33 +ellps=GRS80 \
+step +proj=hgridshift +grids=national-grid-2024.gsb \
+step +proj=unitconvert +xy_in=rad +xy_out=deg" \
-lco GEOMETRY_NAME=geom -lco SPATIAL_INDEX=GIST \
-nlt PROMOTE_TO_MULTI --config OGR_ENABLE_PARTIAL_REPROJECTION NO
Verify a known control point lands where the declared transformation says it should — the only check that catches a wrong-but-valid reprojection:
psql -c "SELECT ST_AsText(ST_SnapToGrid(geom, 0.000001))
FROM ${PRODUCT}_staging WHERE ref_code = 'CTRL-0001';"
# Compare against the control set's published EPSG:4326 position, not against intuition.
3. Run the blocking quality gate
The gate is the platform’s, unmodified. A domain that needs a different gate declares different thresholds, never a different gate.
psql --csv -f pipeline/quality.sql \
-v layer="${PRODUCT}" -v partition="${PARTITION}" \
-v declared_srid=4326 -v max_precision=6 > /tmp/quality.csv
python3 - /tmp/quality.csv <<'PY'
import csv, sys
row = next(csv.DictReader(open(sys.argv[1], newline="")))
if row["publishable"] != "t":
# Fail closed: the previously published version of this partition stays live.
print(f"BLOCKED: validity={row['geometry_validity_rate']} crs={row['crs_conformance']}",
file=sys.stderr)
raise SystemExit(1)
print("quality gate passed")
PY
Verify the gate blocks a deliberately defective partition, so you know it is live rather than merely present:
PARTITION=fixture-invalid make publish 2>&1 | grep -q BLOCKED \
&& echo "the gate is enforcing, not decorative"
4. Build the ports declared in the manifest
Each declared port becomes a build step. A port not declared is not built, and a port declared but not buildable fails the publish rather than silently producing nothing.
# publish.py (step 4) — one builder per declared port type.
BUILDERS = {}
def port(kind):
def register(fn):
BUILDERS[kind] = fn
return fn
return register
@port("tiles")
def build_tiles(spec, ctx):
"""MVT pyramid over the declared matrix set and zoom range."""
z = spec["zoom"]
ctx.run([
"tippecanoe", "-o", f"{ctx.artifact_dir}/tiles.mbtiles",
"-Z", str(z["min"]), "-z", str(z["max"]),
"--projection", "EPSG:3857", # WebMercatorQuad
"--no-tile-compression", ctx.geojson_path,
])
@port("snapshot")
def build_snapshot(spec, ctx):
"""Columnar snapshot for analytical consumers, in the storage CRS."""
ctx.run([
"ogr2ogr", "-f", "Parquet", f"{ctx.artifact_dir}/snapshot.parquet",
f"PG:{ctx.pg_dsn}", "-sql",
f"SELECT * FROM {ctx.product} WHERE partition_key = '{ctx.partition}'",
])
def build_ports(manifest, ctx):
for spec in manifest["spec"]["ports"]:
builder = BUILDERS.get(spec["type"])
if builder is None:
raise SystemExit(f"declared port '{spec['type']}' has no builder")
builder(spec, ctx)
Verify every declared port produced an artifact:
python3 - <<'PY'
import os, yaml
m = yaml.safe_load(open("product.yaml"))
expected = {"tiles": "tiles.mbtiles", "snapshot": "snapshot.parquet"}
missing = [p["type"] for p in m["spec"]["ports"]
if not os.path.exists(f"artifacts/{expected[p['type']]}")]
print("missing ports:", missing or "none")
PY
5. Register atomically — the catalog write is last
Publication is the last step and it is atomic, so a consumer never sees a partially published product.
# The catalog entry is a byproduct of a validated run. Registering before the
# artifacts exist is what produces a catalog advertising data nobody can fetch.
mesh-platform publish \
--manifest product.yaml \
--partition "${PARTITION}" \
--artifacts artifacts/ \
--quality /tmp/quality.csv \
--atomic
# Confirm discoverability, routing and SLO registration in one call.
mesh-platform verify --manifest product.yaml --check catalog,routing,slo
Configuration Reference
| Manifest field | Consumed by | Effect | If absent |
|---|---|---|---|
source.crs |
Reprojection | The authoritative source CRS | Quarantine; never inferred |
source.transformation |
Reprojection | Pins the datum pipeline | Quarantine; PROJ would choose |
storage.crs |
Reprojection, gate | The canonical artifact CRS | Defaults to EPSG:4326 |
quality.blocking |
Gate | Which indicators stop a publish | Platform default applies |
quality.precision_bound |
Gate | Max coordinate decimals | Defaults to 6 |
ports[].type |
Port builders | Which artifacts are built | No port is built |
ports[].matrixSet |
Tile builder | The pyramid grid | Defaults to WebMercatorQuad |
slo.* |
Registration | Alert thresholds wired at publish | No SLO is enforced |
access.principals |
Policy | Who may call the ports | Default deny |
Common Failure Modes & Fixes
The publish succeeds and the product is not discoverable.
Root cause: the catalog write happened but routing registration did not, so the product exists and has no route. Fix: mesh-platform verify --check routing after every publish; a port with no route is healthy and unreachable.
Coordinates are subtly wrong and every check passes.
Root cause: -s_srs/-t_srs without -ct, so PROJ selected a transformation from whatever grids the worker holds. Fix: pin the pipeline as above and verify against a control point rather than by inspection.
Re-publishing a partition creates a second entry. Root cause: the partition key includes a timestamp or a run identifier, so every run is a new partition. Fix: derive the key from the data’s identity — domain, product, partition, CRS, transformation — and nothing about when it ran.
Tiles build but render empty above a zoom level.
Root cause: the declared zoom range exceeds what the source resolution supports, so upper zooms contain no additional detail and tippecanoe drops features. Fix: set zoom.max from the product’s declared resolution rather than from optimism.
The gate passes on a partition that is missing most of its data. Root cause: validity indicators are ratios, and a partition with three valid features is 100% valid. Fix: add an absolute feature-count floor per partition, compared against the trailing median, as a blocking check alongside the ratios.
FAQ
Why is the catalog write last rather than first?
Because a catalog entry is a claim that a consumer can fetch something, and a claim made before the artifact exists is false for as long as the publish takes — which is fine when the publish succeeds and is a live incident when it fails. Writing last means the catalog only ever advertises artifacts that exist and passed their gates, and a failed publish leaves the previous version advertised and serving. The cost is that the catalog lags the artifact by seconds; the benefit is that it is never wrong.
What makes the publish safe to retry?
The partition key. Because it is derived from the data’s identity rather than the run’s, a retried publish resolves to the same key, overwrites the same partition, and produces the same catalog entry — so retrying after a network failure is a no-op if the first attempt completed and a completion if it did not. This is why the key must not contain a timestamp: a key that changes per run turns every retry into a new partition and the catalog fills with duplicates that differ only in when they ran.
Can a domain skip the platform’s quality gate?
They can substitute their own, and they cannot skip having one. The distinction matters: a domain with an unusual product — a point cloud, a raster time series — may genuinely need checks the standard gate does not express, and forcing the standard gate on them produces a check that passes vacuously. What is not negotiable is publishing the same indicator names, so a consumer comparing two products from different domains is comparing the same measurements. The platform enforces the interface, not the implementation.
How does this interact with a product’s version?
The manifest carries the contract version; the publish produces a materialization of it. A publish does not bump the version — a contract change does, through the versioning path, with its own diff and consumer tests. What a publish does bump is the materialization stamp recorded on the artifact, which is what lets a consumer tell two publishes of the same version apart and what freshness monitoring measures against the declared cadence.
Related
- Self-Serve Platform Capabilities for Spatial Teams — the parent topic and the capability set
- Building a Golden Path Template for New Domains — the template whose
make publishruns this - Spatial Data Quality and Validation Standards — the gate this path enforces