Detecting overlay changes that leave the base code untouched

The city adopts a wellhead-protection overlay covering 900 parcels. Two permitted uses are removed from every one of them. Your change detector reports nothing, because every parcel is still zoned R-1 and always was. Nine months later a client asks why your platform never flagged the ordinance that killed their fuel-storage plan, and the answer is that the pipeline was watching the base code — which is exactly what an overlay does not change. This guide closes that hole: it puts overlay membership into the change identity, backfills the amendments already missed, and alerts on them like any other rezone. It is the change-detection half of overlay district modeling.

Diagnosis: measuring the hole before you fix it jump to heading

The hole is measurable directly from your own history, and the number is usually larger than expected.

def missed_overlay_amendments(store, since):
    """Compare overlay membership between consecutive versions of each parcel and
    count the transitions the change feed never reported."""
    missed = []
    for parcel_id, versions in store.iter_version_pairs(since=since):
        for prev, cur in versions:
            if prev.base_code == cur.base_code and \
                    set(prev.overlay_codes) != set(cur.overlay_codes):
                reported = store.change_event_exists(parcel_id, cur.valid_from)
                if not reported:
                    missed.append({
                        "parcel_id": parcel_id,
                        "effective": cur.valid_from,
                        "added": sorted(set(cur.overlay_codes) - set(prev.overlay_codes)),
                        "removed": sorted(set(prev.overlay_codes) - set(cur.overlay_codes)),
                    })
    return missed

Two things fall out of that report. The count itself, which tells you how much regulatory change the pipeline has been blind to; and the clustering — overlay amendments arrive as blocks of hundreds of parcels sharing one effective date, because they are ordinances rather than individual rezones. A report with 900 parcels on one date is one amendment, not nine hundred events, and that distinction matters for how it gets notified.

Amendments the change feed reported, by quarter Stacked bar chart of zoning amendments across four quarters, split into those the change feed reported and those it missed. Base-code rezones were all reported. Overlay-only amendments were all missed: 14 in the first quarter, 19 in the second, 11 in the third and 17 in the fourth, 61 in total against 29 reported rezones. More than two thirds of the year's regulatory change was invisible. Amendments the change feed reported, by quarter 0 10 20 30 14 7 Q1 19 6 Q2 11 9 Q3 17 7 Q4 Amendments adopted overlay-only — missed base rezone — reported Computed from version history; every missed amendment left the base code untouched.
Sixty-one missed against twenty-nine reported: the feed was blind to two thirds of the year's regulatory change, and nothing about it looked broken because every parcel's base code was exactly what it had always been.

If the version history does not record overlays separately, this query cannot be written at all — which is itself the finding, and the reason composing base zoning and overlay districts has to come first.

Step-by-step implementation jump to heading

1. Put the overlay set in the change hash jump to heading

import hashlib
import json


def change_digest(version) -> str:
    """The canonical identity of a parcel's regulatory state.

    Overlays are SORTED, because a portal that returns them in a different order on
    two consecutive runs would otherwise produce a change event every run. They are
    a set, not a sequence — order carries no meaning here.
    """
    payload = {
        "base": version.base_code,
        "overlays": sorted(version.overlay_codes),
        "effective": version.valid_from.isoformat(),
        "geometry": version.canonical_geometry_digest,
    }
    return hashlib.sha256(
        json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    ).hexdigest()

2. Classify the change so it can be handled distinctly jump to heading

An overlay-only amendment is a real change class, not a footnote on rezone, because it needs different notification copy and a different audit trail.

What each change class needs from the pipeline A five-row by four-column matrix of change classes. Rows are no change, boundary adjustment, rezone, overlay-only, and rezone with an overlay change. Columns are whether the base code differs, whether the overlay set differs, whether geometry differs, and how notifications should be grouped. Overlay-only changes are the row where base and geometry are unchanged, and they group by ordinance because a single adoption covers hundreds of parcels at once. What each change class needs from the pipeline base differs overlays differ geometry differs notification grouping no_change no no no none — nothing sent boundary_adjustment no no yes per parcel rezone yes no no per parcel overlay_only no yes no per ordinance rezone_with_overlay_change yes yes sometimes per ordinance stable the signal depends
The grouping column is what makes the class worth having. A rezone is usually one parcel and a sensible unit of notification; an overlay adoption is hundreds of parcels sharing one effective date, and notifying per parcel is how a channel gets muted.
OVERLAY_ONLY = "overlay_only"


def classify(prev, cur) -> str:
    if prev is None:
        return "new_parcel"
    base_changed = prev.base_code != cur.base_code
    overlays_changed = set(prev.overlay_codes) != set(cur.overlay_codes)
    geom_changed = prev.canonical_geometry_digest != cur.canonical_geometry_digest

    if geom_changed and not (base_changed or overlays_changed):
        return "boundary_adjustment"
    if base_changed and overlays_changed:
        return "rezone_with_overlay_change"
    if base_changed:
        return "rezone"
    if overlays_changed:
        return OVERLAY_ONLY          # the class that used to be invisible
    return "no_change"

3. Describe the change in terms of what it does, not what it is jump to heading

An alert saying “overlay WP added” is useless to a subscriber who does not know your overlay vocabulary. The useful alert names the effect, which means resolving standards before and after.

def describe_overlay_change(prev, cur, resolver) -> str:
    before = resolver.resolve(prev)
    after = resolver.resolve(cur)

    added = sorted(set(cur.overlay_codes) - set(prev.overlay_codes))
    removed = sorted(set(prev.overlay_codes) - set(cur.overlay_codes))

    lost_uses = sorted(before.uses - after.uses)
    gained_uses = sorted(after.uses - before.uses)
    numeric = {k: (before.numeric.get(k), v)
               for k, v in after.numeric.items() if before.numeric.get(k) != v}

    parts = []
    if added:
        parts.append("overlay added: " + ", ".join(added))
    if removed:
        parts.append("overlay removed: " + ", ".join(removed))
    if lost_uses:
        parts.append(f"{len(lost_uses)} use(s) no longer permitted: "
                     + ", ".join(lost_uses[:3]) + ("…" if len(lost_uses) > 3 else ""))
    if gained_uses:
        parts.append(f"{len(gained_uses)} use(s) newly permitted")
    for k, (was, now) in numeric.items():
        parts.append(f"{k}: {was} → {now}")
    return "; ".join(parts) or "overlay membership changed with no effect on standards"

The last fallback is worth keeping: some overlay changes are administrative and genuinely alter no standard. Reporting that honestly is better than implying a consequence that does not exist.

4. Group by ordinance, notify once jump to heading

def group_by_amendment(changes):
    """900 parcels sharing an effective date and an overlay delta is ONE ordinance.
    Grouping before notification is what keeps the alert channel usable."""
    from collections import defaultdict
    groups = defaultdict(list)
    for ch in changes:
        if ch.change_class != OVERLAY_ONLY:
            continue
        key = (ch.effective_date, tuple(ch.overlays_added), tuple(ch.overlays_removed))
        groups[key].append(ch.parcel_id)
    return [{"effective": k[0], "added": list(k[1]), "removed": list(k[2]),
             "parcels": v, "parcel_count": len(v)} for k, v in groups.items()]

5. Backfill the amendments you already missed jump to heading

Run the diagnosis query, group the results, and emit the change events with their true effective dates — the same ordered-replay discipline as backfilling a missed week of county feeds. Notify once per amendment, not once per parcel, and label the notifications as historical.

Verification & testing jump to heading

def test_overlay_only_change_is_detected():
    prev = version(base="R-1", overlays=["HO"])
    cur = version(base="R-1", overlays=["HO", "WP"])
    assert classify(prev, cur) == OVERLAY_ONLY
    assert change_digest(prev) != change_digest(cur)


def test_overlay_order_is_not_a_change():
    a = version(base="R-1", overlays=["HO", "WP"])
    b = version(base="R-1", overlays=["WP", "HO"])
    assert classify(a, b) == "no_change"
    assert change_digest(a) == change_digest(b)      # sorted before hashing


def test_description_names_the_effect_not_the_token(resolver):
    prev = version(base="R-1", overlays=[])
    cur = version(base="R-1", overlays=["WP"])
    text = describe_overlay_change(prev, cur, resolver)
    assert "WP" in text
    assert "no longer permitted" in text             # the part a subscriber can act on


def test_one_ordinance_is_one_notification():
    changes = [overlay_change(parcel=f"p{i}", effective="2026-06-01", added=["WP"])
               for i in range(900)]
    groups = group_by_amendment(changes)
    assert len(groups) == 1 and groups[0]["parcel_count"] == 900

The order-independence test is the one that prevents a regression nobody would otherwise notice: without sorting, a portal reordering its overlay array produces 41 000 phantom change events overnight.

Failure recovery jump to heading

Months of overlay amendments never reported. Backfill from the version history as above. Where the history does not separate overlays, the amendments must be recovered from the source archive, and where that is absent, from the jurisdiction’s ordinance record — which is slower but authoritative.

Notifications sent for one 900-parcel ordinance Lollipop chart comparing notification volume for a single wellhead-protection overlay adoption covering 900 parcels, under three approaches. Per-parcel notification sends 900 messages. Grouping by effective date and overlay delta sends 1. Sending nothing, which is what happened before overlay changes were detected at all, sends 0 and is the worst option because subscribers learn about the ordinance from somebody else. Notifications sent for one 900-parcel ordinance 0 200 400 600 800 1000 100 — beyond this the channel gets muted per parcel (ungrouped) 900 grouped by ordinance 1 not detected at all 0 Notifications sent for one ordinance
One ordinance, three possible outcomes. Nine hundred messages mutes the channel; zero messages is how a client discovers the ordinance from their lawyer instead of from you. One grouped notification naming the 900 affected parcels is the only useful answer.

Phantom overlay changes flooding the feed. Almost always unsorted overlay arrays. Normalise, add the sort constraint at the database level, and re-run detection over the affected window to retract the phantom events.

Subscribers who received 900 notifications for one ordinance. Send one grouped summary and enable grouping before doing anything else. Nine hundred alerts for one ordinance is how a channel gets muted, and a muted channel misses the next real change.

Frequently asked questions jump to heading

Why not just include the raw fused zoning string in the hash?

Because it makes the change feed depend on the portal’s formatting. A county that switches from R-1-HO to R-1/HO, or reorders the tokens, would emit a change for every parcel while nothing regulatory happened. Hashing the parsed base plus the sorted overlay set is stable against presentation changes and sensitive to real ones.

Should an overlay-only change alert at the same priority as a rezone?

Yes in substance, and the copy should differ. From a landowner’s point of view, having two permitted uses removed by an overlay is not meaningfully different from a rezone that removes them. What differs is the explanation, which is why the alert resolves standards before and after rather than naming an overlay token the subscriber has never heard of.

Do overlay changes need their own change class?

It pays for itself. The class drives grouping — overlay amendments arrive as hundreds of parcels sharing one effective date, unlike rezones — and it lets a consumer filter for them specifically, which is exactly what somebody auditing “did we ever report this ordinance?” wants to do.

What about an overlay change that alters no standard?

Report it as such. Administrative overlay changes exist — a renamed district, a re-adopted boundary with identical rules — and stating that standards are unaffected is more useful than either silence or an implied consequence. The resolver already computes both sides, so the comparison is free.