Routing Review by Map Extent and Data Sensitivity

A review gate that summons the right person is worth several that summon somebody β€” and for spatial data the right person is usually defined by ground, not by rota. This page is a focused companion to manual review triggers for critical edits.

Concept & Context

Risk scoring decides whether a change needs review. Routing decides who. The two are frequently collapsed into one step, and the result is a queue where every escalated change waits for whichever steward is available β€” which discards the thing that makes spatial review effective.

Local knowledge is not a nice-to-have. A reviewer who works a district knows the parcel boundary follows the hedge and not the fence line, that the access track was reclassified last year, and that a particular field has been disputed for a decade. A reviewer without that context can only check that the geometry is valid, which the pipeline already did.

Responsibility for ground is itself spatial, so the registry mapping extents to reviewer groups is a layer. That makes it queryable by the router, versionable alongside the data, and reviewable when someone disagrees about who owns an area β€” which happens, and is much easier to settle on a map than in a configuration file.

Core Algorithmic Pipeline

  1. Maintain a review-areas layer with a reviewer group per polygon, versioned with the data.
  2. Compute the changed extent from the diff β€” the union of changed features, not the layer’s bounds.
  3. Look up overlapping review areas, with a small buffer so an edit on a boundary reaches both.
  4. Apply sensitivity overrides, which take precedence over geography.
  5. Assign every group found, or the fallback group, and record why each was assigned.
Routing from the changed extent, not the layer extent Two panes over the same set of review areas. The first shows the whole layer extent overlapping every area, which would summon every group. The second shows the buffered union of changed features, overlapping two areas β€” the two groups that actually get assigned. LAYER EXTENT Overlaps every area, so every group is tagged. CHANGED EXTENT Buffered union of changed features, straddling two areas. The buffer is what makes an edit on a boundary reach the groups on both sides of it.

Working Implementation

"""Route a spatial change to reviewer groups by extent and sensitivity."""
from __future__ import annotations

import json
from dataclasses import dataclass, field

import geopandas as gpd
from shapely.ops import unary_union


@dataclass
class Assignment:
    groups: set[str] = field(default_factory=set)
    reasons: list[dict] = field(default_factory=list)

    def add(self, group: str, why: str, detail: str = "") -> None:
        self.groups.add(group)
        self.reasons.append({"group": group, "why": why, "detail": detail})


def changed_extent(before: str, after: str, key: str, buffer_m: float = 25.0):
    """Union of the geometry of everything the change touched, slightly buffered.

    The buffer is what makes an edit right on a review-area boundary reach the
    groups on both sides of it, rather than only the one it technically falls in.
    """
    from spatial_diff import diff_layers

    d = diff_layers(before, after, key=key)
    touched = (set(d.geometry_only) | set(d.added) | set(d.removed)
               | {e["id"] for e in d.both} | {e["id"] for e in d.attribute_only})
    if not touched:
        return None

    head = gpd.read_file(after)
    subset = head[head[key].isin(touched)]
    if subset.empty:                                   # everything was a deletion
        base = gpd.read_file(before)
        subset = base[base[key].isin(touched)]
    return unary_union(subset.geometry.tolist()).buffer(buffer_m)


def route(before: str, after: str, key: str, layer_meta: dict,
          areas_path: str = "governance/review_areas.gpkg",
          fallback: str = "gis-stewards") -> Assignment:
    assignment = Assignment()

    # Sensitivity first: it overrides geography and is never merely additive.
    tier = layer_meta.get("sensitivity_tier", "internal")
    if tier == "restricted":
        assignment.add("data-protection-stewards", "sensitivity",
                       f"layer tier {tier}")
    elif tier == "controlled":
        assignment.add("layer-stewards", "sensitivity", f"layer tier {tier}")

    extent = changed_extent(before, after, key)
    if extent is None:
        assignment.add(fallback, "no-op", "no geometry or attributes changed")
        return assignment

    areas = gpd.read_file(areas_path)
    hits = areas[areas.intersects(extent)]

    for _, area in hits.iterrows():
        overlap = area.geometry.intersection(extent).area
        assignment.add(
            area["reviewer_group"], "extent",
            f"{area['area_name']} ({overlap / 1e6:.2f} kmΒ² of the change)",
        )

    if not hits.empty and len(hits) > 3:
        assignment.reasons.append({
            "group": "", "why": "advice",
            "detail": f"change spans {len(hits)} review areas β€” consider splitting "
                      "it, since each group must approve independently",
        })

    if not assignment.groups:
        assignment.add(fallback, "fallback",
                       "the change falls outside every mapped review area")

    return assignment


def to_pull_request_comment(assignment: Assignment) -> str:
    lines = ["**Review routing**", ""]
    for group in sorted(assignment.groups):
        why = "; ".join(r["detail"] for r in assignment.reasons
                        if r["group"] == group and r["detail"])
        lines.append(f"- `@{group}` β€” {why}")
    advice = [r["detail"] for r in assignment.reasons if r["why"] == "advice"]
    if advice:
        lines += ["", *(f"> {a}" for a in advice)]
    return "\n".join(lines)

The review-areas layer is ordinary spatial data with one required attribute:

ogrinfo -so governance/review_areas.gpkg review_areas
# Layer name: review_areas
#   area_name: String
#   reviewer_group: String        <- a group, never an individual
#   effective_from: Date
#   Feature Count: 42

Naming a group rather than a person is what keeps the gate working during leave, and it is worth enforcing:

areas = gpd.read_file("governance/review_areas.gpkg")
assert areas["reviewer_group"].notna().all(), "every review area needs a group"
assert not areas["reviewer_group"].str.contains("@").any(), \
    "reviewer_group must name a team, not an individual"
Sensitivity is applied first, geography second Three bands describing the router: the sensitivity band is evaluated first and can assign a steward regardless of location, the geography band adds every overlapping area's group, and the fallback band guarantees that something is always assigned. EVALUATED FIRST restricted β†’ protection stewards controlled β†’ layer stewards THEN GEOGRAPHY every overlapping review area's group ALWAYS fallback group, if nothing matched then else Sensitivity has to win: a restricted layer must not be reviewed by whoever happens to own that ground.

Validation & Output Verification

# Coverage: no populated ground may fall outside every review area
import geopandas as gpd
from shapely.ops import unary_union

areas = gpd.read_file("governance/review_areas.gpkg")
parcels = gpd.read_file("data/parcels.gpkg")

covered = unary_union(areas.geometry.tolist())
orphans = parcels[~parcels.geometry.intersects(covered)]
print(f"{len(orphans)} feature(s) outside every review area")
assert len(orphans) / len(parcels) < 0.001, "review areas do not cover the data"

# Overlap: two groups reviewing the same ground is a governance question
import itertools
for a, b in itertools.combinations(areas.index, 2):
    if areas.loc[a].geometry.intersects(areas.loc[b].geometry):
        shared = areas.loc[a].geometry.intersection(areas.loc[b].geometry).area
        if shared > 1000:
            print(f"overlap: {areas.loc[a, 'area_name']} / "
                  f"{areas.loc[b, 'area_name']} β€” {shared / 1e6:.2f} kmΒ²")
# Routing must be deterministic for a given change
for i in 1 2 3; do
  python scripts/route_review.py --before base.gpkg --after head.gpkg \
    | jq -S '.groups'
done | uniq | wc -l      # expected: 1

# And a restricted layer must route to its steward regardless of extent
python scripts/route_review.py --before base.gpkg --after head.gpkg \
  --layer-meta '{"sensitivity_tier": "restricted"}' \
  | jq -r '.groups[]' | grep -q data-protection-stewards && echo "override applies"
What the review-areas layer has to guarantee A grid of four properties of the review-areas registry β€” coverage, group naming, overlap and versioning β€” against what breaks when each is not held, and the check that detects it. If it does not hold Check Covers the data extent changes route to nobody features outside every area Names groups, not people the gate stops during leave reviewer_group contains no address Areas do not overlap duplicate review, unclear ownership pairwise intersection area Versioned with the data routing cannot be reconstructed the registry is in the repository All four are ordinary spatial checks, which is the argument for keeping the registry as a map layer.

Failure Modes

  • Every change routes to every group β€” symptom: a pull request tagging eight teams. Root cause: routing on the layer’s extent rather than the changed extent. Fix: compute the union of changed features, as above.

  • A change reaches nobody β€” symptom: a merge request sits unreviewed. Root cause: the change fell outside every mapped area and there was no fallback. Fix: always assign the fallback group, and treat frequent fallbacks as a sign the registry needs extending.

  • A sensitive edit routed only by geography β€” symptom: a restricted layer reviewed by a local group without clearance. Root cause: sensitivity treated as one signal among several. Fix: apply sensitivity first and let it override, as the router does.

  • Boundary edits reach only one side β€” symptom: a shared boundary approved by one district. Root cause: no buffer on the changed extent. Fix: buffer by the survey tolerance or a little more, so both groups are summoned.

Back to Manual Review Triggers for Critical Edits