Zoning Change Alerting

A detection engine that finds a rezoning is worthless if the people who care about that parcel never hear about it, hear about it six times, or get paged at 3 a.m. because a county re-published its entire zoning layer overnight. Zoning change alerting is the stage that sits between raw change events and human or machine consumers: it decides who cares about a given parcel or jurisdiction, collapses duplicate signals into a single notification, ranks each event by how much it actually matters, and delivers it over the right channel with delivery guarantees that survive a flaky webhook endpoint. This topic is part of the Spatial Impact Analysis & Zoning Change Detection framework, and it is the last mile — the difference between a detection pipeline that quietly logs deltas to a table and one that drives underwriting decisions, portfolio risk dashboards, and planning-department review queues in near real time. Get the routing and deduplication wrong and you train every subscriber to ignore your alerts; get it right and a boundary change on a single tax lot reaches exactly the analysts who hold exposure to it, once, within seconds.

Zoning change alerting dispatch flow Change events from the detection layer enter a spatial subscription match backed by an STRtree index, which selects the watchers whose areas of interest intersect the changed parcel. Matched events pass through a deduplication and idempotency gate keyed on a content hash with a time-to-live window, then a severity classifier ranks each event, then an async delivery dispatcher sends over webhook, email, or queue with exponential backoff. Deliveries that exhaust their retries fall to a dead-letter store. A suppression gate on the inbound edge silences the pipeline during bulk layer reprocessing so a full county re-publish does not produce an alert storm. suppress on bulk reprocess Change events diff · overlay parcel · geometry Match watchers STRtree index AOI intersect Dedupe content hash idempotency · TTL Classify severity use-change · area route by rank Deliver webhook · email queue · backoff retries spent Dead-letter replayable · audited spatial + routing logic exactly-once gate delivery with guarantees From change event to the right subscriber, exactly once
The dispatcher matches change events to watchers by geometry, collapses duplicates behind an idempotency key, ranks severity, and delivers with backoff; unrecoverable deliveries fall to a replayable dead-letter store.

Prerequisites and operational context jump to heading

Alerting is a downstream stage, and it inherits the quality of everything upstream of it. Before the dispatcher below earns its keep, several contracts need to be in place:

  • A stable change-event schema. Every alert begins as a structured change event emitted by the detection layer — a record with a parcel_id, the changed geometry or its bounding box, the old and new zoning classification, a source jurisdiction, and a monotonic event timestamp. Both change detection and geometry diffing and spatial overlay analysis produce these events; the dispatcher never re-derives them. If the upstream event shape drifts, deduplication silently breaks, because the content hash it depends on is computed over those exact fields.
  • A subscription registry with geometry. A watcher is not a row of email addresses — it is an area of interest (a portfolio boundary, a study area, a redevelopment district) plus a delivery target and a severity threshold. Those geometries must be reprojected to one working CRS before they enter the spatial index, or an intersection test will miss a parcel that is genuinely inside a subscriber’s footprint.
  • Provenance stamped on every event. Each change event should carry a lineage reference so that an alert can be traced back to the exact source feed and detection run that produced it. That linkage comes from data lineage and provenance tracking, and it is what lets a subscriber ask “why did I get this?” and receive a defensible answer.
  • A durable retry substrate. Delivery over a network will fail intermittently. The dispatcher assumes an at-least-once delivery attempt with bounded retries and a dead-letter destination, mirroring the same discipline used for feed acquisition under error handling and retry logic. Without it, a webhook 503 either drops the alert or blocks the whole pipeline.

If any of these are shaky, fix them before tuning delivery. Alerting amplifies upstream defects: a duplicated event becomes a duplicated page, and a mislabelled jurisdiction becomes an alert to the wrong subscriber.

Architecture: spatial matching, the idempotency gate, and delivery jump to heading

The dispatcher has three logically distinct concerns, and keeping them separate is what makes the system testable. The first is spatial subscription matching — given a changed parcel, which watchers’ areas of interest overlap it? A naive implementation loops over every subscription and runs a Shapely intersects for each incoming event; at a few hundred subscriptions and a few thousand daily events that is already tens of millions of geometry comparisons. The scalable answer is to build a spatial index over the subscription geometries once and query it per event. Shapely 2.x ships STRtree, a static R-tree that answers a bounding-box query in logarithmic time; the index returns candidate subscriptions whose envelopes overlap the parcel, and only those candidates get an exact intersects test. The index is built from the subscription registry and rebuilt when subscriptions change, not per event.

The second concern is exactly-once semantics, enforced by an idempotency gate. Municipal feeds are not transactional — a county re-publishing its zoning layer will re-emit thousands of “changes” that are byte-identical to what you already alerted on last week. The gate computes a deterministic content hash over the fields that define the meaning of an alert — subscriber, parcel, old class, new class, effective date — and checks whether that key has already been delivered inside a time-to-live window. If it has, the event is dropped as a duplicate. The idempotency key is stored, not just the hash of the payload, because the payload can carry volatile fields (a fetch timestamp, a request id) that must not participate in equality.

The third concern is delivery with guarantees. Different channels offer different trade-offs: an internal queue is fire-and-forget with the queue itself providing durability; a webhook demands an acknowledgement and a retry policy; email is high-latency and effectively one-shot. The dispatcher classifies each event’s severity first — a use-class change from residential to heavy-industrial on a large parcel is not the same as a metadata correction — and routes by rank, so that only material changes escalate to synchronous, paged channels while informational deltas batch into a digest. Delivery runs asynchronously with a bounded concurrency and exponential backoff, and anything that exhausts its retries lands in a dead-letter store for replay rather than being lost.

Sitting across the inbound edge is a suppression gate. When the ingestion layer is doing a bulk reprocess — a full re-crawl of a county after a schema migration, say — it can emit a torrent of change events that are technically new to the detection layer but are not news to a subscriber. A suppression window, keyed on a reprocess flag or an event-rate threshold, routes those events into the dedupe store to prime it without firing notifications, so the storm is absorbed silently.

Production implementation jump to heading

The dispatcher below is written for a municipal zoning feed. It builds an STRtree over subscription geometries, matches each change event to overlapping watchers, deduplicates behind an idempotency key with a TTL, classifies severity, and delivers asynchronously with jittered backoff and a dead-letter fallback. It is complete enough to run against an aiohttp session, a Redis-backed idempotency store, and a subscription list.

import asyncio
import hashlib
import json
import logging
import random
import time
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Callable, Dict, List, Optional, Sequence

import aiohttp
from shapely import STRtree
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry

logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("zoning_alerting")

MAX_RETRIES = 4
BASE_DELAY = 0.5
DELIVERY_CONCURRENCY = 16
DEFAULT_TTL_SECONDS = 7 * 24 * 3600  # a re-published layer within a week is a dup
RETRYABLE_STATUS = {429, 500, 502, 503, 504}


class Severity(IntEnum):
    INFO = 1        # attribute correction, no land-use effect
    NOTICE = 2      # boundary nudge, same use class
    MATERIAL = 3    # use-class change or overlay added/removed
    CRITICAL = 4    # material change on a large or high-value parcel


@dataclass(frozen=True)
class Subscription:
    watcher_id: str
    geometry: BaseGeometry          # area of interest, already in working CRS
    channel: str                    # "webhook" | "email" | "queue"
    target: str                     # URL, address, or queue name
    min_severity: Severity = Severity.NOTICE


@dataclass
class ChangeEvent:
    parcel_id: str
    jurisdiction: str
    old_zoning: str
    new_zoning: str
    effective_date: str
    geometry: dict                  # GeoJSON geometry of the changed parcel
    lineage_id: str                 # provenance handle from the detection run
    is_reprocess: bool = False      # set by ingestion during a bulk re-crawl
    _geom: Optional[BaseGeometry] = field(default=None, repr=False)

    @property
    def geom(self) -> BaseGeometry:
        if self._geom is None:
            self._geom = shape(self.geometry)
        return self._geom


class IdempotencyStore:
    """Thin async wrapper over a TTL key store (Redis SET NX EX in production)."""

    def __init__(self, redis, ttl: int = DEFAULT_TTL_SECONDS):
        self._redis = redis
        self._ttl = ttl

    async def claim(self, key: str) -> bool:
        """Return True if this is the first time we've seen key (claim it).

        SET NX makes the check-and-set atomic, so two workers racing on the
        same event cannot both deliver it.
        """
        # returns True only when the key did not already exist
        return bool(await self._redis.set(key, "1", nx=True, ex=self._ttl))

    async def prime(self, key: str) -> None:
        """Record a key WITHOUT triggering delivery (suppression path)."""
        await self._redis.set(key, "1", nx=True, ex=self._ttl)


def idempotency_key(sub: Subscription, ev: ChangeEvent) -> str:
    """Deterministic content hash over the fields that define the alert's meaning.

    Volatile fields (fetch time, request id, lineage) are excluded so that a
    re-published but semantically identical event collapses to the same key.
    """
    payload = "|".join([
        sub.watcher_id, ev.parcel_id, ev.jurisdiction,
        ev.old_zoning, ev.new_zoning, ev.effective_date,
    ])
    return "alert:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()


def classify(ev: ChangeEvent) -> Severity:
    """Rank a change by land-use impact, not by how the feed labelled it."""
    if ev.old_zoning == ev.new_zoning:
        return Severity.INFO
    residential = {"R1", "R2", "R3", "RA"}
    intensive = {"M1", "M2", "I1", "I2", "C3"}
    escalates = ev.old_zoning in residential and ev.new_zoning in intensive
    # area in the working CRS; a large upzoned parcel is what wakes people up
    try:
        large = ev.geom.area > 5_000.0
    except Exception:  # empty or invalid geometry: never crash the classifier
        large = False
    if escalates and large:
        return Severity.CRITICAL
    if escalates:
        return Severity.MATERIAL
    return Severity.NOTICE


class SubscriptionMatcher:
    """STRtree over subscription geometries; candidate query then exact test."""

    def __init__(self, subscriptions: Sequence[Subscription]):
        self._subs = list(subscriptions)
        self._tree = STRtree([s.geometry for s in self._subs])

    def match(self, ev: ChangeEvent) -> List[Subscription]:
        parcel = ev.geom
        if parcel.is_empty:
            return []
        # STRtree.query returns integer indices of envelope-overlapping geoms
        hits = self._tree.query(parcel)
        return [self._subs[i] for i in hits
                if self._subs[i].geometry.intersects(parcel)]


async def _deliver_once(
    session: aiohttp.ClientSession, sub: Subscription, body: dict
) -> bool:
    """One delivery attempt for one channel. Returns True on success."""
    if sub.channel == "webhook":
        async with session.post(sub.target, json=body, timeout=15) as resp:
            if resp.status in RETRYABLE_STATUS:
                resp.raise_for_status()  # signal the retry loop
            resp.raise_for_status()
            return True
    if sub.channel == "queue":
        # queue puts are durable by construction; treat enqueue as success
        await session.app_queue.put(sub.target, body)  # type: ignore[attr-defined]
        return True
    if sub.channel == "email":
        await session.mailer.send(sub.target, body)     # type: ignore[attr-defined]
        return True
    raise ValueError(f"unknown channel: {sub.channel}")


async def deliver(
    session: aiohttp.ClientSession,
    sub: Subscription,
    body: dict,
    dead_letter: Callable[[Subscription, dict, str], "asyncio.Future"],
) -> bool:
    """At-least-once delivery with capped exponential backoff + jitter."""
    for attempt in range(MAX_RETRIES):
        try:
            return await _deliver_once(session, sub, body)
        except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
            if attempt < MAX_RETRIES - 1:
                delay = BASE_DELAY * (2 ** attempt) + random.uniform(0, 0.4)
                await asyncio.sleep(delay)
                continue
            logger.error("delivery to %s failed after %d tries: %s",
                         sub.watcher_id, MAX_RETRIES, exc)
            await dead_letter(sub, body, str(exc))
            return False
        except ValueError as exc:  # misconfigured channel: not retryable
            logger.error("permanent delivery error for %s: %s", sub.watcher_id, exc)
            await dead_letter(sub, body, str(exc))
            return False
    return False


async def dispatch(
    events: List[ChangeEvent],
    matcher: SubscriptionMatcher,
    store: IdempotencyStore,
    session: aiohttp.ClientSession,
    dead_letter: Callable[[Subscription, dict, str], "asyncio.Future"],
) -> Dict[str, int]:
    """Match -> dedupe -> classify -> deliver, with reprocess suppression."""
    sem = asyncio.Semaphore(DELIVERY_CONCURRENCY)
    stats = {"matched": 0, "duplicate": 0, "suppressed": 0,
             "sent": 0, "failed": 0, "below_threshold": 0}
    tasks = []

    async def _send(sub: Subscription, ev: ChangeEvent, sev: Severity):
        async with sem:
            body = {
                "watcher_id": sub.watcher_id, "parcel_id": ev.parcel_id,
                "jurisdiction": ev.jurisdiction, "severity": sev.name,
                "from_zoning": ev.old_zoning, "to_zoning": ev.new_zoning,
                "effective_date": ev.effective_date, "lineage_id": ev.lineage_id,
                "sent_at": time.time(),  # volatile: NOT part of the idempotency key
            }
            ok = await deliver(session, sub, body, dead_letter)
            stats["sent" if ok else "failed"] += 1

    for ev in events:
        sev = classify(ev)
        for sub in matcher.match(ev):
            stats["matched"] += 1
            if sev < sub.min_severity:
                stats["below_threshold"] += 1
                continue
            key = idempotency_key(sub, ev)
            if ev.is_reprocess:
                # prime the store so a later genuine event still dedupes,
                # but do NOT notify during a bulk re-crawl
                await store.prime(key)
                stats["suppressed"] += 1
                continue
            if not await store.claim(key):
                stats["duplicate"] += 1
                continue
            tasks.append(asyncio.create_task(_send(sub, ev, sev)))

    if tasks:
        await asyncio.gather(*tasks, return_exceptions=True)
    logger.info("dispatch complete: %s", stats)
    return stats

Several choices are deliberate. The idempotency key is claimed atomically with SET NX before a delivery task is scheduled, so two workers processing the same event cannot both fire — the loser sees the key already exists and counts a duplicate. Volatile fields such as sent_at are added to the delivered body but excluded from the key, so re-emission of the same logical change still collapses. The classify function ranks by land-use impact and parcel size rather than trusting the feed’s own labelling, and the suppression path calls prime rather than claim so a bulk reprocess seeds the dedupe store without paging anyone. Delivery uses return_exceptions=True at the gather so one hung webhook never cancels its siblings.

Edge cases and gotchas jump to heading

Alerting fails in ways that are invisible until a subscriber complains. Design for these up front:

  • STRtree returns envelopes, not geometries. The tree query is a bounding-box filter; an L-shaped subscription boundary will return a parcel that sits in the box’s notch but outside the polygon. Always follow the candidate query with an exact intersects (or overlaps) test, as the matcher above does. Skipping it produces false alerts to watchers who do not actually cover the parcel.
  • Idempotency TTL versus legitimate re-changes. If a parcel really is rezoned twice inside your TTL window — proposed, then amended — a key that ignores effective_date will swallow the second, genuine change as a duplicate. Include the effective date (or a change sequence number) in the key so a truly new decision produces a new key.
  • Subscription geometry in the wrong CRS. A watcher polygon stored in EPSG:4326 degrees while parcels arrive in a State Plane system in feet will never intersect, and the subscriber silently receives nothing. Normalize every subscription to the working CRS at registry-load time and assert bounds are plausible.
  • Webhook that 200s but drops the body. Some endpoints acknowledge receipt and then fail internally. At-least-once delivery cannot detect this; where it matters, require the receiver to be idempotent on your idempotency_id and reconcile from the audit log rather than trusting the HTTP status alone.
  • Severity inflation. If everything is CRITICAL, nothing is. Keep the escalation rule narrow — a real use-class jump on a material parcel — and let boundary nudges and metadata fixes settle into a batched digest instead of a page.
  • Unbounded fan-out on a metro-wide change. A single overlay that touches an entire downtown can match thousands of subscriptions at once. Bound the delivery concurrency (the semaphore above) and, for very large matches, coalesce per-watcher events into one summarized notification rather than one alert per parcel.
  • Dead-letter that no one drains. A dead-letter store is only useful if it is monitored and replayed. Alert on its depth, and make replay re-enter the same dispatch path so replayed events still deduplicate against anything delivered in the meantime.

Delivery channel reference jump to heading

Channels differ in latency and in the delivery guarantee they can offer. Pick per subscriber and per severity, not globally.

Channel Typical latency Guarantee Retry model Best for
Internal queue (SQS / Redis Streams) sub-second At-least-once (queue-durable) Broker redelivery + DLQ Machine consumers, downstream pipelines
Webhook (HTTP POST) 1–5 s At-least-once (needs receiver idempotency) App backoff + dead-letter Real-time dashboards, third-party systems
Email (transactional) seconds–minutes Effectively at-most-once Provider retry only Human notification, low volume
Digest (batched email/queue) minutes–hours At-least-once per window Re-batch on next window INFO/NOTICE roll-ups
Paged alert (on-call) seconds At-least-once, escalation Escalation policy CRITICAL land-use changes

Integration points jump to heading

Alerting is defined by what feeds it. Its inputs are the change events produced by change detection and geometry diffing, which computes the geometric and attribute deltas between two snapshots of a jurisdiction, and by spatial overlay analysis, which resolves what a change means at the parcel level by intersecting new zoning with the parcel fabric. The dispatcher does not recompute either — it consumes their events and treats their field set as a fixed contract, which is exactly why the idempotency hash can be stable. On the delivery side, when a webhook or downstream consumer is itself flaky, the retry and dead-letter behaviour here composes with the broader error handling and retry logic patterns used across ingestion, so a subscriber outage is absorbed the same way a source outage is. For the operational build-out of a single notifier — the subscription index, the exactly-once gate, and the audit trail wired together end to end — see the companion guide on building a zoning change notification pipeline in Python.

Compliance and audit artifacts jump to heading

For PropTech underwriting and planning-department review, an alert is a claim about a public decision, and it has to be provable after the fact. Every dispatch run should persist:

  • A delivery ledger. One immutable row per delivery attempt: the idempotency key, watcher id, parcel id, severity, channel, attempt count, final status, and the lineage_id that ties the event back through data lineage and provenance tracking to the exact source feed and detection run. This is what answers “why did I receive this alert, and where did the underlying data come from?”
  • A deduplication record. Which events were suppressed as duplicates and which were primed during a reprocess, so a gap in a subscriber’s alert history is explainable rather than suspicious.
  • A severity rationale. The classifier inputs (old class, new class, area) and the resulting rank, retained so a disputed escalation can be reconstructed exactly, not re-guessed against today’s rules.
  • Dead-letter provenance. Every dead-lettered delivery with its failure reason and the payload as sent, retained immutably so a missed notification can be replayed and audited.

Together these turn an alert stream from an ephemeral notification into a defensible, reconstructable chain from municipal publication to the analyst’s inbox.

FAQ jump to heading

How do I stop a full county re-publish from triggering an alert storm?

Flag reprocess events at the ingestion boundary and route them through a suppression path that primes the idempotency store without delivering. The keys are recorded so a later genuine change still deduplicates, but no notification fires during the bulk re-crawl. Combine that with an event-rate circuit breaker that trips when inbound volume for a jurisdiction spikes far above its baseline.

What exactly should the idempotency key be computed over?

Only the fields that define the meaning of the alert to a specific subscriber: watcher id, parcel id, jurisdiction, old zoning, new zoning, and effective date. Exclude volatile fields such as fetch timestamps, request ids, or lineage handles, because those change on every re-emission and would defeat deduplication. Claim the key atomically with SET NX EX so racing workers cannot both deliver.

Why use an STRtree instead of looping over subscriptions?

A linear scan runs an exact geometry test for every subscription on every event, which is millions of GEOS calls at modest scale. An STRtree answers a bounding-box query in logarithmic time and returns only candidate subscriptions whose envelopes overlap the parcel; you then run the exact intersects test on that short list. The index is built once from the registry and rebuilt when subscriptions change, not per event.

How do I choose a delivery channel and guarantee per alert?

Classify severity first, then route. Informational and low-severity deltas batch into a digest; material and critical land-use changes escalate to real-time webhooks or paged channels. Use durable queues for machine consumers, require receiver idempotency on webhooks, and treat email as effectively one-shot. Anything that exhausts retries lands in a monitored dead-letter store for replay.

What happens to an alert whose webhook is down for hours?

It is retried with capped exponential backoff and jitter, and once retries are exhausted it is written to a dead-letter store with its failure reason and the payload as sent — never dropped silently. The dead-letter depth is monitored and alerted on, and replay re-enters the same dispatch path so a replayed event still deduplicates against anything delivered in the meantime.

Up: Spatial Impact Analysis & Zoning Change Detection