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.
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(oroverlaps) 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_datewill 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_idand 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_idthat 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.
Related jump to heading
- Building a zoning change notification pipeline in Python — the end-to-end notifier build, step by step
- Change Detection & Geometry Diffing — produces the change events this dispatcher consumes
- Spatial Overlay Analysis — resolves parcel-level impact that feeds severity classification
- Error Handling & Retry Logic — the retry and dead-letter discipline delivery reuses
- Data Lineage & Provenance Tracking — the provenance stamped on every alert