Configuring Review Gates for Cadastral Boundary Edits
Cadastral boundaries carry legal and financial weight, so an automated pipeline must stop and demand human eyes when an edit moves a parcel line, touches a neighbour, or rewrites ownership β one concrete implementation of manual review triggers for critical edits.
Concept & Context
Most spatial edits are safe to merge automatically once they pass validation. A handful are not. When someone moves a parcel boundary, the change can shift a property line, reassign a strip of land, or contradict a registered survey β outcomes that belong in front of a licensed surveyor, not a merge bot. The challenge is telling the two apart without forcing a human to review every commit, which quickly desensitizes reviewers and defeats the purpose.
A review gate answers this by encoding risk rules that a pipeline evaluates on every pull request. Three rules cover the majority of cadastral exposure: a parcel area change above a threshold (a boundary was materially moved), a shared-boundary touch (an edited edge is common to a neighbouring parcel, so moving it silently alters that neighbour), and an ownership attribute change (the legal owner, title reference, or parcel identifier was rewritten). When any rule fires, the gate blocks the automatic path and routes the change to named reviewers.
This complements rather than replaces geometry validation. Where automated conflict detection in merge requests asks is this edit topologically valid, a review gate asks is this edit consequential enough to need a person. The two run side by side, and both draw on the same branch-versus-base diff. This guide builds the gate as a scripted check that emits a machine-readable report, wired to a CODEOWNERS entry so approval authority is unambiguous.
Core Review-Gate Pipeline
- Define the risk rules. Fix explicit thresholds: an area change beyond, say, 0.5% or 5 mΒ²; any geometry edit on a boundary shared with another parcel; any change to the ownership, title, or parcel-ID attribute. Put these constants in one place so they are auditable and tunable.
- Diff the edited layer against its base. Load the branch layer and the base revision, and match features by their stable parcel identifier β never by feature order, which is unstable across edits.
- Classify each changed parcel. For every matched pair, compute the geometry and attribute deltas, test each rule, and record which rule fired with the offending values.
- Fail the gate on any flag. Emit a report and exit non-zero when it is non-empty, so branch protection blocks the merge.
- Require named reviewers. A CODEOWNERS entry on the cadastral data path ensures the flagged pull request cannot merge without sign-off from the survey authority team.
Working Implementation
The check below loads the branch and base copies of a parcel layer with GeoPandas, matches on a parcel_id, and evaluates the three rules. It writes a report and returns a non-zero exit code when anything is flagged. The trailing YAML wires it into a CI gate and shows the CODEOWNERS line that pins approval to the survey team.
#!/usr/bin/env python3
"""
Cadastral review gate: flag risky parcel edits and block auto-merge.
Usage:
python cadastral_gate.py base_parcels.gpkg branch_parcels.gpkg
Exit codes:
0 no risky edits β safe to auto-merge
1 one or more parcels tripped a rule β human review required
"""
import sys
import geopandas as gpd
# --- Risk rules (single source of truth; tune per jurisdiction) --------------
AREA_CHANGE_RATIO = 0.005 # 0.5% relative area change
AREA_CHANGE_ABS_M2 = 5.0 # or 5 square metres absolute, whichever is smaller
OWNERSHIP_FIELDS = ("owner", "title_ref", "parcel_id")
ID_FIELD = "parcel_id"
def load_indexed(path):
"""Load a parcel layer indexed by its stable identifier."""
gdf = gpd.read_file(path)
if gdf.crs is None or gdf.crs.is_geographic:
raise SystemExit(f"{path}: needs a projected CRS so area is in metres.")
return gdf.set_index(ID_FIELD, drop=False)
def flag_edits(base, branch):
"""Return a list of (parcel_id, rule, detail) for every risky change."""
flags = []
common = branch.index.intersection(base.index)
for pid in common:
b_old, b_new = base.loc[pid], branch.loc[pid]
# Rule 1: material parcel area change.
a_old, a_new = b_old.geometry.area, b_new.geometry.area
delta = abs(a_new - a_old)
if delta > AREA_CHANGE_ABS_M2 or delta > a_old * AREA_CHANGE_RATIO:
flags.append((pid, "area_change", f"{a_old:.1f}->{a_new:.1f} m2"))
# Rule 3: ownership / legal attribute change.
for field in OWNERSHIP_FIELDS:
if str(b_old.get(field)) != str(b_new.get(field)):
flags.append((pid, "ownership_change",
f"{field}: {b_old.get(field)!r}->{b_new.get(field)!r}"))
# Rule 2: shared-boundary touch β any edited geometry touching a neighbour.
changed = [pid for pid in common
if not branch.loc[pid].geometry.equals(base.loc[pid].geometry)]
if changed:
sindex = branch.sindex
for pid in changed:
geom = branch.loc[pid].geometry
for nbr_pos in sindex.query(geom, predicate="touches"):
nbr_id = branch.iloc[nbr_pos][ID_FIELD]
if nbr_id != pid:
flags.append((pid, "shared_boundary", f"touches {nbr_id}"))
return flags
def main(base_path, branch_path):
base, branch = load_indexed(base_path), load_indexed(branch_path)
flags = flag_edits(base, branch)
if not flags:
print("Review gate: no risky cadastral edits detected.")
return 0
print("Review gate: manual review REQUIRED for the following parcels:")
for pid, rule, detail in flags:
print(f" parcel {pid}: {rule} ({detail})")
return 1
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python cadastral_gate.py base_parcels.gpkg branch_parcels.gpkg")
sys.exit(2)
sys.exit(main(sys.argv[1], sys.argv[2]))
# .github/workflows/cadastral-review-gate.yml
name: Cadastral Review Gate
on:
pull_request:
paths: ["data/parcels/**"]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: pip install geopandas
- name: Materialize base and branch parcel layers
run: |
git show "origin/${{ github.base_ref }}:data/parcels/parcels.gpkg" > base_parcels.gpkg
cp data/parcels/parcels.gpkg branch_parcels.gpkg
- name: Evaluate risk rules
run: python tools/cadastral_gate.py base_parcels.gpkg branch_parcels.gpkg
# CODEOWNERS (repository root) β pins approval to the survey authority team:
# /data/parcels/ @org/survey-authority
Validation & Output Verification
Verify the gate does both jobs: pass clean edits and stop risky ones. Run it against a base and a branch where only a street label changed and confirm a zero exit and the βno risky editsβ message:
python tools/cadastral_gate.py base_parcels.gpkg branch_labels_only.gpkg
echo "exit=$?" # expect exit=0
Then run it against a branch where a boundary was moved and confirm the non-zero exit plus a named parcel in the report:
python tools/cadastral_gate.py base_parcels.gpkg branch_moved_line.gpkg
echo "exit=$?" # expect exit=1, with the parcel_id and rule printed
On the platform side, confirm branch protection actually honours the gate: open a test pull request touching data/parcels/, check that the gate job appears as a required status check, and confirm the CODEOWNERS review from @org/survey-authority is requested automatically. The gate is only as strong as the branch-protection rule that makes it required β without that, a failing check is advisory. Treat the rule thresholds as versioned configuration, aligning with how attribute reconciliation for tabular spatial data keeps reconciliation policy under review.
Failure Modes
- Gate passes a moved boundary because features were matched by order β feature ordering changes when a layer is rewritten, so positional matching pairs the wrong parcels. Fix: always match on the stable
parcel_idindex, as the script does, and fail loudly if an id is missing. - Area rule fires on every parcel β the layer is in a geographic CRS, so
.areais in square degrees and every value trips the absolute threshold. Fix: reproject to a projected CRS in metres before computing area; the loader rejects geographic CRS for exactly this reason. - Shared-boundary rule misses a real touch β sliver gaps or tiny overshoots mean neighbours do not satisfy a strict
touchespredicate. Fix: snap geometries to a tolerance before the spatial query, or widen the predicate to a small-buffer intersection so near-touches are still caught. - CODEOWNERS never requests the survey team β the path glob in CODEOWNERS does not match where the parcels actually live, so no reviewer is added. Fix: confirm the glob matches the changed files exactly and that the entry sits on the default branch, where CODEOWNERS is read from.
Related
- Manual Review Triggers for Critical Edits β parent guide on when and how to escalate high-consequence spatial edits to human review
- Automated Conflict Detection in Merge Requests β the topology and geometry validation that runs alongside a review gate
- Attribute Reconciliation for Tabular Spatial Data β reconciling the ownership and title attributes that a cadastral gate protects