Security Boundaries in Spatial QA

A security boundary in geospatial testing is a deterministic, enforceable line that validation code is not permitted to cross: a tenant whose geometry must never join another tenant’s, a coordinate that must never round outside its sensitivity tier, or a memory ceiling a topology check must never breach. As a strategy within Geospatial QA Fundamentals & Architecture, boundary enforcement reframes security as something the test suite asserts on every commit rather than something an auditor inspects after the fact. This matters in spatial pipelines because the failure modes are silent: a botched coordinate reference system (CRS) transform or an over-permissive bounding box does not throw — it produces plausible, wrong, and sometimes confidential output that flows straight into tile servers and analytical warehouses. The patterns here turn isolation, tolerance, and access control into executable predicates that run inside the same gates as your geometry and schema checks.

Classifying a spatial QA security boundary into four enforceable families A root node, "Spatial QA security boundary", splits into four columns. Column one, isolation and tenancy, enforces no cross-tenant join with the predicate ST_Disjoint. Column two, precision and drift, keeps output within the sensitivity grid with equals_exact within tolerance. Column three, resource and memory, prevents heap exhaustion with a bounded batch cap. Column four, access and redaction, keeps sensitive coordinates from a shared sink with snap-to-grid. Each family box feeds a predicate box below it. Spatial QA security boundary Isolation / tenancy no cross-tenant join Precision / drift stay within grid tier Resource / memory bound the heap Access / redaction keep coords off sink ST_Disjoint hard fail · zero overlap equals_exact(tol) symmetric epsilon bounded batch cap R-tree pre-filter snap-to-grid coarsen + null mask

A Taxonomy of Spatial Security Boundaries

Spatial boundaries fall into four enforceable families, and each maps to a distinct predicate, tolerance strategy, and failure signature. Treating them as one undifferentiated “security” concern is the most common reason boundary checks are too loose to catch real leaks. Before writing any geometric assertion you should pin the spatial tolerance thresholds for the family in play, because a tenant-isolation check and a precision-drift check tolerate error in opposite directions.

Boundary family What it enforces Core predicate Tolerance strategy Threshold range CRS units
Isolation / tenancy No cross-tenant or cross-classification spatial join ST_Disjoint, ST_Intersects Hard fail, zero overlap Exact (no slack) n/a (relation)
Precision / drift Output geometry stays within sensitivity-tier grid equals_exact(g, tol) Symmetric epsilon 1e-61e-9 deg; 1e-3 m degrees / metres
Resource / memory Validation cannot exhaust the runner heap bounded batch size Hard cap per batch 5k–50k features n/a
Access / redaction Sensitive coordinates never reach a shared sink snap-to-grid, attribute mask Coarsen + null grid ≥ 100 m metres

The threshold ranges are deliberately CRS-aware. A 1e-6 epsilon means roughly 0.11 m at the equator in WGS84 (EPSG:4326) but is meaningless in a projected metre-based system such as EPSG:25832, where you instead express tolerance directly in millimetres. Mixing the two is itself a boundary violation — see the gotchas below.

Isolation Boundaries: Tenant and Classification Separation

Isolation boundaries guarantee that geometries from different tenants, security classifications, or licensing tiers are never silently combined. The predicate is relational rather than metric: adjacent administrative zones owned by different tenants must satisfy ST_Disjoint, while a feature claimed by tenant A must ST_Contains only A’s geometries. In Shapely 2.x the vectorised form reads shapely.disjoint(tenant_a, tenant_b), and the assertion fails closed — any returned False is a leak, not a warning. Because spatial joins are the usual leak vector, scope every join through the dataset’s sensitivity tier as defined by your scoping rules for map data validation, so a high-throughput batch can never widen its extent past the tier it was granted.

import shapely

# Isolation assertion: no geometry may straddle two tenant envelopes.
def assert_tenant_isolation(geom, own_envelope, foreign_envelope) -> None:
    assert shapely.contains(own_envelope, geom), "geometry escaped its own tenant scope"
    assert shapely.disjoint(geom, foreign_envelope), "geometry intersects a foreign tenant"

Precision Boundaries: Tolerance and Coordinate Drift

Precision boundaries cap how far a transformed or round-tripped geometry may move from its source. The risk is twofold: too tight a tolerance floods CI with false positives from ordinary floating-point noise, while too loose a tolerance lets a real CRS or datum error pass as “close enough.” The correct test is a symmetric relative-error bound. For two geometries AA and BB the boundary holds when the Hausdorff distance stays under a CRS-scaled epsilon:

dH(A,B)=max{supaAinfbBab,  supbBinfaAab}εd_H(A, B) = \max\left\{\sup_{a \in A} \inf_{b \in B} \lVert a - b \rVert,\; \sup_{b \in B} \inf_{a \in A} \lVert a - b \rVert\right\} \le \varepsilon

In Shapely 2.x use a.equals_exact(b, tolerance=eps) for vertex-level equality within eps, or shapely.hausdorff_distance(a, b) for the envelope above. Load eps from config keyed on CRS units — never hardcode it — and pin it to the source data’s acquisition accuracy.

import shapely

def assert_within_tolerance(source, transformed, eps: float) -> None:
    # eps is in the CRS unit of `transformed` (degrees for 4326, metres for projected).
    assert shapely.hausdorff_distance(source, transformed) <= eps, "geometry drifted past tolerance"

Resource Boundaries: Memory-Safe Validation

Resource boundaries stop a validation run from becoming a denial-of-service against your own CI. Operations such as ST_Buffer, dense ST_Intersects matrices, and full-table topology validation balloon memory on large polygon sets or high-density point clouds. The boundary is a hard cap on the working set: pre-filter with an R-tree or Quadtree, stream features in bounded batches, and let each batch fall out of scope before the next loads. The pattern pairs naturally with mocking geospatial data for tests, since synthetic fixtures let you exercise the cap deterministically without pulling production-sized dumps onto a shared runner.

import shapely
from shapely import STRtree

def validate_in_batches(geoms, predicate_geom, batch=10_000):
    tree = STRtree(geoms)                       # R-tree pre-filter
    candidates = tree.query(predicate_geom)     # only nearby features
    for start in range(0, len(candidates), batch):
        window = candidates[start:start + batch]  # bounded working set
        yield shapely.intersects(geoms.take(window), predicate_geom)

A Runnable Boundary Suite with pytest

Boundary checks belong in the same harness as the rest of your assertions so they share fixtures, tolerance config, and failure reporting. The suite below loads tolerance from config, exercises all three metric families, and fails fast. It assumes Shapely 2.x and pytest 7+, and it mirrors the unit-versus-integration split covered in when to use unit vs integration tests in GIS — these boundary predicates are pure and in-memory, so they run on every commit.

import shapely
import pytest

# tolerance_config.yaml -> loaded once per session
TOLERANCE = {"epsg_4326_deg": 1e-6, "epsg_25832_m": 1e-3}

@pytest.fixture(scope="session")
def eps():
    return TOLERANCE["epsg_4326_deg"]

def test_tenant_isolation(tenant_a, tenant_b):
    # Hard boundary: zero tolerance for cross-tenant overlap.
    assert shapely.disjoint(tenant_a, tenant_b)

def test_transform_precision(source_geom, reprojected_geom, eps):
    # Symmetric boundary: drift must stay under the CRS-scaled epsilon.
    assert shapely.hausdorff_distance(source_geom, reprojected_geom) <= eps

def test_redaction_coarsens_below_grid(sensitive_point):
    redacted = shapely.set_precision(sensitive_point, grid_size=100.0)  # metres
    # Boundary: redacted output must not equal the original vertex.
    assert not sensitive_point.equals_exact(redacted, tolerance=0.0)

Run the family under a Great Expectations 0.18 custom expectation when the checks must live beside data-quality docs rather than code tests; the predicate is identical, only the wrapper differs.

PostGIS Boundary Counterparts

Server-side enforcement complements the Python suite by catching violations the moment data is written, before any client reads it. The relational predicates translate directly, and a CHECK or trigger turns the boundary into a constraint the database itself refuses to break. Pin libgeos and PROJ versions in the image so the same ST_* semantics run in CI and production.

-- Isolation: reject any insert whose geometry crosses a foreign tenant extent.
ALTER TABLE features ADD CONSTRAINT no_cross_tenant
  CHECK (ST_Disjoint(geom, (SELECT extent FROM foreign_tenant_bounds)));

-- Precision: a reprojection view that flags drift past tolerance.
SELECT f.id
FROM features f
JOIN features_4326 s ON s.id = f.id
WHERE ST_HausdorffDistance(ST_Transform(f.geom, 4326), s.geom) > 1e-6;  -- degrees

-- Redaction: coarsen sensitive points to a 100 m grid on egress.
SELECT id, ST_SnapToGrid(geom, 100.0) AS geom FROM sensitive_assets;

Pipeline Integration

Boundary predicates only protect production if they sit on a blocking gate. Wire the pytest suite into the pre-merge job so an isolation or precision failure rejects the merge, and run the heavier batched resource checks as a nightly job against staging-scale fixtures. Pin the geometry stack in the container — libgeos, PROJ, and GDAL — because a minor PROJ bump can shift a datum transform by centimetres and quietly trip a tolerance boundary. Emit structured logs (boundary_family, predicate, tolerance, crs, result) so observability tooling can alert on a rising assertion-failure rate before a rollback is needed. This gating shape is shared across the site’s spatial test pattern design and implementation work, and the synthetic inputs it depends on come from test data generation and mocking strategies; the tiered placement of cheap-versus-expensive checks follows the GIS test pyramid, and the CRS gate itself is detailed in automating CRS validation in CI pipelines.

The Re-identification Boundary

The boundary that most spatial suites miss entirely is the one between a coordinate and a person. Location is quasi-identifying in a way few other attributes are: a handful of visited points is close to unique for an individual, and a single residential coordinate is effectively an identifier on its own. This has a direct, unglamorous consequence for testing — a test fixture derived from real movement or address data is personal data, and it inherits every obligation the production dataset carries, including retention limits and deletion requests.

The practical defence is a redaction ladder applied before the data ever reaches a repository, with the strength chosen by what the test actually needs to observe. Detail is dropped in the order that costs the test the least.

Technique What it preserves What it destroys Right when the test asserts
Truncate coordinate precision Rough position, topology at coarse scale Sub-metre position Format, schema, CRS handling
Snap to a grid cell Cell membership, counts per cell Position within the cell Aggregation and binning logic
Spatial jitter within a radius Statistical distribution Individual position Density and heat-map behaviour
Replace with synthetic geometry Structure and volume only All real position Everything else
Aggregate to an administrative area Area-level totals Point-level anything Reporting and roll-up rules

The bottom row of that table is the one to reach for by default. Most spatial tests assert on structure — is the geometry valid, does the join return the right cardinality, is the CRS preserved — and structure is exactly what synthetic generation reproduces perfectly. Real coordinates buy realism that the assertions were never reading.

The redaction ladder: identifying detail removed at each step Five horizontal bars, descending. The top bar, truncate precision, is drawn longest to show the highest remaining re-identification risk, and is annotated as sufficient only for format and schema assertions. Snap to grid is shorter, suitable for aggregation logic. Jitter within a radius is shorter again, suitable for density behaviour. Synthetic replacement is short and marked as the default choice, suitable for structure, validity, joins and CRS. Aggregate to an administrative area is shortest, suitable only for reporting roll-ups. A downward arrow on the left is labelled re-identification risk falling, and a note on the right states that most spatial assertions read structure, which synthetic data reproduces exactly. REDACTION STEP RE-IDENTIFICATION RISK ASSERTIONS STILL VALID Truncate precision format · schema · CRS Snap to grid cell + binning · counts per cell Jitter within radius + density · heat-map shape Synthetic replacement default choice structure · validity · joins · CRS Aggregate to area reporting roll-ups only risk falls Most spatial assertions read structure, not position — so the cheapest row for the test is usually also the safest row for the data subject.

Two failure modes recur when teams try to do this well. The first is redacting the geometry but not the attributes: jittering a point while leaving a household identifier, a timestamp sequence, or a rare categorical value attached re-identifies just as effectively as the coordinate did. The second is redacting at read time rather than at capture time, which leaves the unredacted data in a repository, a CI cache, and every developer’s checkout. Redaction has to happen in the job that produces the fixture, and the raw input must never be written where the test tooling can see it.

Boundary Failures Are Silent by Construction

Security boundaries differ from correctness boundaries in one respect that shapes how they must be tested: crossing them usually produces no error. A query that returns another tenant’s polygons returns valid geometry. A log line that echoes a coordinate is a well-formed log line. A test that loads a production extract runs green. Nothing in the stack objects, which means the only way a boundary breach becomes visible is if something is specifically watching for it.

That makes negative assertions the core technique here, and they need to be written more carefully than positive ones. A positive assertion fails when the feature breaks; a negative assertion fails only when the guard breaks, and a guard that was never exercised looks identical to a guard that works. The discipline that closes this gap is to prove the guard can fail: each boundary test should include a case that deliberately crosses the line and asserts the crossing is caught. A tenant-isolation test that only ever queries in-scope data proves nothing; one that queries out-of-scope data and asserts an empty result proves the filter exists.

A boundary test that cannot fail proves nothing Two panels compared. The incomplete panel sends only an in-scope query through the filter; both a working filter and a deleted filter return the same in-scope rows, so the test is green either way and the guard is never exercised. The complete panel adds an out-of-scope query: when the filter is present the result is empty and the assertion passes, and when the filter is removed the query returns another tenant's rows and the assertion fails. A caption states that only the arrangement with a deliberate crossing can tell a working guard from an absent one. Incomplete — in-scope query only in-scope query tenant filter present or missing same rows returned either way → green the guard is never invoked deleting it changes nothing the test can see Complete — adds a deliberate crossing out-of-scope query tenant filter filter present empty result → pass filter removed other tenant’s rows → fail the two cases now differ — the guard is real Every boundary test needs a case that crosses the line. Without one, a deleted guard and a working guard are the same shade of green.

The same reasoning applies to log redaction. Asserting that a normal run emits no coordinates is weak, because a normal run may have had none to emit. Asserting that a run given a geometry that triggers an error still emits no coordinates is the test that matters, since the error path is exactly where raw payloads leak into messages.

Frequently Asked Questions

Is a bounding box really identifying?

Often, yes. A tight bounding box around a single feature reveals the feature’s position almost as precisely as the geometry does, and bounding boxes are routinely logged as “metadata” precisely because they look harmless. Treat the box as carrying the same classification as the geometry it wraps, and coarsen it before it reaches a log or an error message.

How do we keep production data out of test environments without blocking debugging?

Provide a sanctioned path instead of relying on discipline. A short-lived, access-logged workspace where an engineer can reproduce an issue against real data, with no ability to copy it out, removes the incentive to quietly download an extract. The failure mode you are guarding against is not malice, it is a tired engineer at the end of an incident.

Should spatial injection tests use real attack payloads?

Use crafted payloads that exercise the parsing and quoting paths — deeply nested collections, enormous coordinate counts, coordinates at the limits of the type, strings containing quote and comment characters. They belong in the suite as ordinary fixtures. What you should not do is point exploit tooling at a shared environment, which turns a unit test into an incident.

Where do audit requirements actually bite in a test suite?

At the point where the suite reads regulated data, which most teams do not realise counts as access. If cadastral or personal location data is read by a CI job, that job’s identity, the query it ran, and the extent it touched belong in the audit trail exactly as a human’s would. Designing fixtures so the suite never touches regulated data is usually cheaper than instrumenting the suite to be auditable.

Does encryption at rest solve the fixture problem?

No. It protects against media theft, not against the everyday paths that leak fixtures: a repository clone, a CI artefact, a debug log, a screenshot in a ticket. The property you need is that the data in the fixture is not sensitive in the first place, which is why synthetic generation does more for this boundary than any storage control.

Common Failure Modes and Gotchas

  1. CRS unit mismatch in the epsilon. A 1e-6 tolerance is ~0.11 m in EPSG:4326 but a tenth of a millimetre in a metre-based projection — applying a degree epsilon to projected coordinates makes every drift check pass. Always key tolerance on the CRS unit.
  2. DE-9IM boundary-touching slip. ST_Intersects returns true for geometries that merely share an edge, so two tenants touching along a shared border read as overlapping. Use ST_Disjoint for isolation and ST_Overlaps/ST_Crosses when interior overlap is the real concern.
  3. Snap-to-grid sliver artifacts. Redaction via set_precision/ST_SnapToGrid can collapse a thin polygon into an invalid or empty geometry; assert validity after coarsening rather than assuming the output is still a usable feature.
  4. Anti-meridian-spanning bounding boxes. A naive tenant envelope across ±180° longitude expands to nearly the whole globe, so the isolation check silently permits every join. Split the geometry at the anti-meridian or test in a projected CRS.
  5. Unbounded buffer before the pre-filter. Calling ST_Buffer on the full table before applying the R-tree query reintroduces the OOM the resource boundary was meant to prevent — filter first, buffer the survivors.
  6. Tolerance config drifting from container PROJ. Pinning the epsilon but not the PROJ/GDAL version lets a base-image bump move transforms underneath a fixed threshold; version both together.

Conclusion

Security boundaries become trustworthy only when they are deterministic, versioned, and executed on every commit instead of inspected after deployment. By separating isolation, precision, resource, and redaction boundaries — each with its own predicate and CRS-aware tolerance — a spatial QA suite catches the silent leaks and drift that generic security review misses. For the wider architecture these boundaries plug into, return to Geospatial QA Fundamentals & Architecture.