Best Practices for Defining Trust Boundaries in PostGIS

A trust boundary in PostGIS is the exact point at which a spatial row stops being “incoming data” and becomes a fact that downstream queries, tiles, and cross-region replicas are allowed to depend on. This page narrows in on a single operational problem: deciding where in a PostGIS node that line is drawn, what validation and access checks enforce it, and how you instrument it so a breach surfaces as a metric instead of a 2 a.m. topology incident. It sits under Monitoring Topology for Multi-Region GIS — where these boundaries become the egress control points between regions — and within the broader Geospatial Observability Architecture & Fundamentals discipline. It is written for the data engineers, GIS platform administrators, SREs, and compliance teams who own the gap between raw ingestion and trusted production geometry.

Trust-boundary routing for an inbound PostGIS feature An INSERT or UPDATE feature enters the route_to_quarantine trigger, which tests whether the geometry is valid and tagged with SRID 4326. Valid features pass straight into the trusted public.cadastral_parcels table; invalid or wrong-SRID features are diverted to the quarantine.cadastral_parcels table together with the ST_IsValidReason cause. INSERT / UPDATE feature route_to_quarantine trigger ST_IsValid AND ST_SRID = 4326? yes public.cadastral_parcels trusted geometry no quarantine. cadastral_parcels + ST_IsValidReason

Problem Framing

Tabular trust boundaries are usually a single concern — authorization. Spatial trust boundaries are three concerns stacked on the same row: geometric validity (is the polygon self-intersecting?), referential correctness (is it in the SRID the rest of the stack assumes?), and provenance (is this source allowed to write into a production feature class?). When any one of these is unguarded, the failure is silent. A self-intersecting ring passes a NOT NULL check, lands in public.cadastral_parcels, and only manifests hours later as a ST_Intersection returning an empty geometry or a GiST index that refuses to prune.

The signals that tell you a boundary is missing or leaking are specific: a rising count of rows where ST_IsValid(geom) is false, spatial queries whose mean execution time climbs as the planner falls back to sequential scans, and CRS-tagged metrics that disagree between the ingestion and publishing stages. The affected pipeline stage is almost always the transformation tier — reprojection, topology validation, and spatial joins — because that is where an unvalidated upstream feed first gets trusted by an expensive operation. Drawing the boundary correctly means moving these three checks as early as the ingestion edge and making the boundary itself observable, which is the conceptual core of Defining Spatial Data Trust Boundaries. Exactly which signals are eligible to cross the boundary at each tier is constrained by the Observability Scoping Rules for Vector Data.

Trust-boundary zones across a PostGIS node and its regional egress point On one PostGIS node, raw INSERT and UPDATE features hit a trust-boundary gate in the ingestion zone that checks ST_IsValid, ST_SRID equals 4326, and role plus row-level-security provenance. Valid rows flow through the transformation zone (ST_Transform and spatial joins on trusted geometry) into the publishing zone (vector tiles and cross-region replicas). Invalid rows are diverted to a quarantine schema annotated with ST_IsValidReason. Published telemetry leaves the node through a regional edge collector that acts as the egress control point between regions. Single PostGIS node Ingestion Raw feature INSERT / UPDATE Trust boundary gate ST_IsValid ST_SRID = 4326 role + RLS provenance Transformation ST_Transform spatial joins trusted geometry Publishing vector tiles cross-region replicas valid invalid quarantine schema + ST_IsValidReason Regional edge collector egress control point

Implementation

The boundary is built in three layers on the same node: a schema-level validity gate, a quarantine reject path, and role/row-level access enforcement. Treat them as one unit — a validity gate without provenance control still lets a compromised ETL role write technically-valid-but-unauthorized geometry.

Schema-level validity gates

Trust begins at the table definition. Embed the validity and SRID contract directly in DDL so the boundary cannot be bypassed by any writer. For low-volume critical feature classes (cadastral parcels, emergency response zones), enforce it synchronously with CHECK constraints:

-- Critical table: the boundary contract is the table definition itself
CREATE TABLE public.cadastral_parcels (
    parcel_id    UUID PRIMARY KEY,
    geom         GEOMETRY(Polygon, 4326),       -- typed column rejects wrong geometry type + SRID
    ingestion_ts TIMESTAMPTZ DEFAULT NOW(),
    CONSTRAINT valid_geometry CHECK (ST_IsValid(geom) = TRUE),  -- blocks self-intersections
    CONSTRAINT correct_srid   CHECK (ST_SRID(geom) = 4326)      -- blocks silent CRS drift
);

The typed GEOMETRY(Polygon, 4326) column already rejects the wrong geometry type and a mismatched SRID at write time; the two CHECK constraints add the validity guarantee the column type cannot express. This is the same SRID contract enforced in the Coordinate Reference System Validation workflow, applied here as a hard write barrier rather than a freshness check.

Quarantine reject path

A synchronous CHECK constraint aborts the whole transaction on one bad row, which is unacceptable for high-throughput sensor or telemetry feeds. For those, move the boundary into a BEFORE trigger that diverts invalid rows into a quarantine schema while letting the batch proceed, capturing ST_IsValidReason(geom) for triage:

CREATE SCHEMA IF NOT EXISTS quarantine;

CREATE TABLE quarantine.cadastral_parcels (
    original_id   UUID,
    geom          GEOMETRY(Polygon, 4326),
    failed_reason TEXT,                        -- human-readable cause from ST_IsValidReason
    quarantined_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE OR REPLACE FUNCTION route_to_quarantine() RETURNS TRIGGER AS $$
BEGIN
    IF NOT ST_IsValid(NEW.geom) OR ST_SRID(NEW.geom) != 4326 THEN
        INSERT INTO quarantine.cadastral_parcels (original_id, geom, failed_reason)
        VALUES (NEW.parcel_id, NEW.geom, ST_IsValidReason(NEW.geom));
        RETURN NULL;   -- RETURN NULL stops the row entering the main table without aborting the batch
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER validate_cadastral_geom
BEFORE INSERT OR UPDATE ON public.cadastral_parcels
FOR EACH ROW EXECUTE FUNCTION route_to_quarantine();

The RETURN NULL is the whole point: it silently drops the offending row from the main table while keeping the rest of the multi-row statement intact, and the quarantine insert preserves the geometry plus its failure reason for a later ST_MakeValid repair pass.

Provenance: role and row-level boundaries

Validity is not authorization. PostGIS functions such as ST_Transform, ST_Buffer, and ST_Intersection are computationally expensive and can be weaponized through unbounded queries, so the boundary must also restrict who writes and which classification of rows they touch. Define minimal roles and a Row-Level Security policy keyed to a per-session classification (see the PostgreSQL Row-Level Security reference):

CREATE ROLE gis_reader      NOLOGIN;
CREATE ROLE gis_etl_writer  NOLOGIN;

GRANT SELECT          ON ALL TABLES IN SCHEMA public  TO gis_reader;
GRANT INSERT, UPDATE  ON ALL TABLES IN SCHEMA staging TO gis_etl_writer;  -- writers land in staging, not public

ALTER TABLE public.spatial_assets ENABLE ROW LEVEL SECURITY;
CREATE POLICY classification_filter ON public.spatial_assets
    USING (data_classification = current_setting('app.user_classification', true));

Then bound the cost a writer can impose. Per-role statement_timeout and parallel-worker caps stop a single unbounded ST_Buffer from saturating the node, and full statement logging gives the boundary an audit trail:

ALTER ROLE gis_etl_writer SET log_statement = 'all';
ALTER ROLE gis_etl_writer SET statement_timeout = '30s';
ALTER ROLE gis_etl_writer SET max_parallel_workers_per_gather = 2;

Make the boundary observable

A boundary you cannot see is a boundary you cannot trust. Scrape the quarantine growth rate and slow-spatial-query signal with the OpenTelemetry Collector Contrib sqlquery receiver so the boundary feeds the same namespace defined in the Geospatial Metric Taxonomy for ETL:

# otel-collector-config.yaml — contrib build, runs beside the PostGIS node
receivers:
  sqlquery:
    driver: postgres
    datasource: "host=postgis-primary port=5432 user=otel_monitor password=${PG_OTEL_PASS} sslmode=require"
    collection_interval: 30s
    queries:
      - sql: >
          SELECT count(*) AS count
          FROM quarantine.cadastral_parcels
          WHERE quarantined_at > NOW() - INTERVAL '15 minutes'
        metrics:
          - metric_name: "gis.spatial.quarantine_growth_rate"
            value_column: "count"
            value_type: int
      - sql: >
          SELECT avg(mean_exec_time) AS avg
          FROM pg_stat_statements
          WHERE query LIKE '%ST_%' AND mean_exec_time > 500
        metrics:
          - metric_name: "gis.spatial.slow_query_avg_ms"
            value_column: "avg"
            value_type: double

The wiring for spans and resource attributes that surround these metrics is covered in OpenTelemetry Integration for GIS Pipelines; here the receiver only needs read access to the quarantine table and pg_stat_statements.

Verification & Testing

Confirm the boundary holds before you rely on it. Inject a known self-intersecting polygon and assert it never reaches the trusted table:

-- Synthetic bad geometry: a bow-tie polygon (self-intersection)
INSERT INTO public.cadastral_parcels (parcel_id, geom)
VALUES (
  gen_random_uuid(),
  ST_GeomFromText('POLYGON((0 0, 1 1, 1 0, 0 1, 0 0))', 4326)
);

-- Expect 0 rows in the trusted table from this batch, 1 in quarantine
SELECT (SELECT count(*) FROM quarantine.cadastral_parcels
        WHERE quarantined_at > NOW() - INTERVAL '1 minute') AS quarantined,
       failed_reason
FROM quarantine.cadastral_parcels
ORDER BY quarantined_at DESC LIMIT 1;

A correct boundary returns quarantined = 1 with a failed_reason like Self-intersection. Next, verify the alerting threshold actually fires. The boundary is breached when the quarantine growth over a 15-minute window crosses the safe rate rq=Δnq15min>1000r_{q} = \frac{\Delta n_{q}}{15\,\text{min}} > 1000; encode that as a Prometheus rule and validate it against the synthetic load:

groups:
  - name: postgis_trust_boundary
    rules:
      - alert: HighQuarantineVolume
        expr: increase(gis_spatial_quarantine_growth_rate[15m]) > 1000
        for: 5m
        labels: { severity: critical }
        annotations:
          summary: "Spatial quarantine exceeding safe threshold"
          description: "Invalid-geometry ingestion > 1000 rows/15m — verify ETL CRS mapping."
      - alert: SpatialFunctionRateLimitExceeded
        expr: rate(gis_spatial_function_calls_total[1m]) > 500
        for: 2m
        labels: { severity: warning }
        annotations:
          summary: "Spatial function call rate limit breached"
          description: "Role rate limit exceeded — check for unbounded ST_Buffer / ST_Intersection."

Finally, confirm the validity gate did not quietly cost you index health — a boundary that forces sequential scans trades one failure mode for another:

SELECT indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE indexrelname LIKE '%geom%';

Gotchas & Failure Modes

SRID mismatch passing a validity-only gate. ST_IsValid says nothing about the coordinate system. A geometry can be perfectly valid and in the wrong SRID, so a boundary that checks only ST_IsValid lets CRS drift through. Always pair the validity check with ST_SRID — the typed column plus the correct_srid constraint above closes this, and the patterns in Geometry Validity & Topology Checks cover the topology edge cases the type system cannot.

ST_MakeValid silently rewriting topology. Repairing quarantined rows with ST_MakeValid(geom) can split a polygon into a MultiPolygon or drop slivers, changing area and vertex count. Re-ingest repaired rows through an idempotent INSERT ... ON CONFLICT DO UPDATE and re-assert ST_NPoints and ST_Area deltas against the original before trusting them — do not assume “made valid” means “unchanged.”

RLS bypass through SECURITY DEFINER functions. A spatial helper function declared SECURITY DEFINER executes with the owner’s privileges and ignores the calling role’s RLS policy, quietly punching a hole in the provenance boundary. Audit every SECURITY DEFINER function that touches a row-level-secured table and prefer SECURITY INVOKER unless elevation is deliberate.

When a boundary breach does cascade, degrade rather than fail. The tiered response below mirrors the strategy in Fallback Chains for Spatial API Failures:

Tier Trigger Action
1 Primary node slow / lock contention Route reads to a replica with warmed spatial indexes
2 Sustained ST_* latency over budget Serve simplified geometry via ST_Simplify(geom, tolerance)
3 Validation gate degraded Return ST_Envelope(geom) with an X-Geometry-Simplified: true header

Frequently Asked Questions

Should I use a synchronous CHECK constraint or a quarantine trigger?

Use synchronous CHECK constraints for low-volume, high-criticality feature classes where a single bad row should abort the transaction (cadastral, emergency zones). Use the quarantine trigger for high-throughput feeds where blocking the whole batch on one invalid geometry is worse than diverting that row for later repair. Many production nodes run both: constraints on trusted tables, quarantine routing on staging.

Why does an invalid geometry still pass my type-checked GEOMETRY column?

A GEOMETRY(Polygon, 4326) column enforces geometry type and SRID, but not validity. A self-intersecting bow-tie is still a polygon in 4326, so it satisfies the column type and only an explicit ST_IsValid constraint or trigger rejects it. Type and validity are independent guarantees and you need both.

How do I keep the trust boundary from triggering sequential scans?

The validity gate runs on write, not read, so it does not directly cause scans — missing or bloated GiST indexes do. After enabling the boundary, watch idx_scan in pg_stat_user_indexes and run EXPLAIN (ANALYZE, BUFFERS) on your hot spatial queries; a sudden drop in index scans usually means an index needs a REINDEX or the planner lost statistics after a bulk quarantine-repair reload.

Where should the boundary live in a multi-region deployment?

At the regional edge, not the central backend. The regional collector is the egress control point that strips high-precision coordinate attributes and enforces label allowlists before telemetry leaves the jurisdiction, as laid out in Monitoring Topology for Multi-Region GIS. Pushing the validity and provenance checks to the edge keeps a partition in one region from exporting unvalidated geometry into another.

My quarantine table is growing but queries look fine — what now?

Rising quarantine volume with healthy query latency almost always means an upstream CRS mapping changed and every incoming row now fails the ST_SRID check. Inspect failed_reason distribution first; if it is dominated by SRID mismatches rather than self-intersections, fix the ETL reprojection step, not the database.