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.

python
# 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

The publish path, with the catalog write deliberately lastA publish resolves the partition key from the data identity, refuses to proceed on an undeclared source CRS, reprojects with the pinned transformation, runs the blocking quality gate, builds every declared port, and writes the catalog entry atomically last. Two rails record the fail-closed points: an undeclared CRS quarantines rather than guessing, and a failed quality gate leaves the previously published version live. Writing the catalog last means it only ever advertises artifacts that exist and passed their gates.Partition keyfrom data identityReprojectpinned pipelineQuality gateblockingBuild portsas declaredCatalog writeatomic, lastresolvedreprojectedpassedbuiltcrs undeclaredgate blockedPrevious version stays liveno partial publish exists

Verify the key is stable and the CRS gate bites:

bash
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.

bash
# 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:

bash
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.

bash
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:

bash
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.

python
# 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:

bash
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.

bash
# 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

Why the partition key makes a retried publish safeA publish is submitted and its partition key is derived from the data identity — domain, product, partition, CRS and transformation — never from when it ran. The first attempt completes the artifact and writes the catalog entry. A retry after a lost response resolves to the same key, finds the partition already materialized, and completes without producing a second entry. The final exchange records the alternative: a key containing a timestamp makes every retry a new partition, and the catalog fills with duplicates differing only in when they ran.PipelinePublishCatalogpublish partition 2026-08entry written atomicallyRETRY, response lostsame key → same entrytimestamped key → duplicate entry

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

Which manifest field each step consumes, and what it refuses to inferSix manifest fields against the step that consumes them and the behaviour when the field is absent. Source CRS and transformation both quarantine rather than defaulting, because inference produces valid geometry in the wrong place. Storage CRS, precision bound and matrix set all fall back to platform defaults, because a wrong default there is visible rather than silent. Port types default to nothing, so an undeclared port is simply not built rather than guessed at.Consumed byIf absentsource.crsreprojectionquarantinesource.transformationreprojectionquarantinestorage.crsreprojection, gatedefaults to EPSG:4326quality.precision_boundgatedefaults to 6ports[].typeport buildersnothing is builtports[].matrixSettile builderdefaults to WebMercatorQuad

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.