Encoding setback and height variance precedence rules
A proposed tower modification rarely fails against a single rule. It fails, or passes, against three of them stacked on top of each other: the base ordinance cap and setback that apply by default, a granted variance that may override that cap for one specific site, and a grandfathered exemption from a permit issued before the ordinance existed at all. Which one governs when more than one applies is not a judgment call — it is a fixed order, and if two reviewers apply that order differently, one of them approves a structure the other denies for the same set of facts. This page builds the precedence resolver that sits inside the Zoning Rule Engine Design: a small stdlib module that takes a proposed height and setback, walks baseline, variance, and grandfathering in a fixed order, and returns a verdict with a stated reason, sealed against the exact ordinance version that produced it.
Prerequisites & Context
Before running the code below, have the following in place:
- Python 3.10+, standard library only. The resolver is built from
dataclasses,enum,datetime,hashlib, andjson— no third-party dependency. Frozen dataclasses keep the ordinance baseline, the variance, and the exemption record immutable, so the inputs a verdict was computed against can never drift after the fact. - A canonical zoning code and ordinance version per jurisdiction. Every evaluation is keyed to a
MUN-4A-RES-style designation and a revision string, mirroring the identifiers established in Building Zoning Compliance Rule Engines in Python; this page assumes that baseline evaluation already exists and focuses on what happens when a variance or a grandfathered permit complicates it. - A variance record with an explicit scope and expiry, not a boolean flag. “Variance on file: yes/no” is not enough to resolve precedence correctly — a variance that only covers height cannot rescue a setback violation, and a variance that expired last year cannot rescue anything. The variance record needs to say exactly what it covers, what limit it grants, and when it lapses.
- A record of where the ordinance and permit data itself comes from. Baseline caps, variance grants, and grandfather permits are filed with different municipal offices on different schedules; keeping that filing pipeline synchronized with the record this resolver reads is the concern of Regulatory Filing Synchronization, and a stale variance expiry reaching this resolver is exactly the failure mode that upstream sync is meant to prevent.
Step-by-Step Implementation
Step 1 — Define the base cap and setback per zoning code. Represent the ordinance as a frozen OrdinanceBaseline carrying zoning_code, max_height_ft, min_setback_ft, and version. This is the default answer for every proposal in that jurisdiction — everything downstream either confirms it or explains why it doesn’t apply.
Step 2 — Model a granted Variance that can override the cap. A variance is not a blanket override; it is scoped. Give it covers_height and covers_setback booleans, the specific granted_height_ft and granted_setback_ft limits it actually permits, and an expiry date. A variance that covers height but not setback must never be allowed to excuse a setback violation, and one whose expiry has passed must never be treated as active.
Step 3 — Model a grandfathered exemption as its own record, not a fallback flag. A GrandfatherExemption carries the permit_id that predates the ordinance and the exempt_height_ft / exempt_setback_ft values that permit actually allowed. It sits behind the variance in precedence — a legacy permit does not override a newer variance decision, it only catches what neither the baseline nor an active variance resolves.
Step 4 — Evaluate the proposal through the precedence chain and return an explainable verdict. Check baseline compliance first. If the proposal already satisfies the cap and setback, stop there — APPROVED_BASELINE, no variance or exemption needed. If it doesn’t, check whether an active, correctly scoped variance covers exactly the dimension that failed. If not, check whether the grandfather exemption covers it. Every branch returns both a Verdict enum member and a plain-text reason string, because “denied” without a reason is not something a permit reviewer can defend or challenge.
Step 5 — Seal the verdict and the ordinance version with hashlib.sha256. Serialize the site ID, zoning code, ordinance version, verdict, and reason with sorted keys and hash the result. The ordinance version inside that hash is what lets an auditor tell a legitimate re-evaluation against an amended ordinance apart from a tampered record claiming the same verdict against the same version.
Figure: baseline compliance is checked first; only an unresolved gap gives a scoped, unexpired variance a chance to override it, and only a gap the variance doesn’t cover gives a grandfather exemption a chance — with denial the fallback when no precedent applies, and every terminal sealed by a SHA-256 audit hash.
Complete Runnable Example
The module below implements all five steps: immutable records for the baseline, variance, and exemption, a precedence resolver that returns a typed verdict with a reason, a custom VariancePrecedenceError, structured logging, and a SHA-256 audit hash sealed over the ordinance version.
# Resolve setback and height variance precedence for a proposed tower modification.
import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import date, datetime, timezone
from enum import Enum
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("variance_precedence")
class VariancePrecedenceError(Exception):
"""Raised for non-physical geometry or a variance record that expired before evaluation."""
class Verdict(str, Enum):
APPROVED_BASELINE = "APPROVED_BASELINE"
APPROVED_VARIANCE = "APPROVED_VARIANCE"
APPROVED_GRANDFATHERED = "APPROVED_GRANDFATHERED"
DENIED = "DENIED_NO_PRECEDENT_APPLIES"
@dataclass(frozen=True)
class OrdinanceBaseline:
zoning_code: str # e.g. MUN-4A-RES
max_height_ft: float
min_setback_ft: float
version: str # ordinance revision, e.g. 2026.2
@dataclass(frozen=True)
class Variance:
variance_id: str # e.g. VAR-2214
covers_height: bool
covers_setback: bool
granted_height_ft: float
granted_setback_ft: float
expiry: date
@dataclass(frozen=True)
class GrandfatherExemption:
permit_id: str # e.g. CUP-1990-07
exempt_height_ft: float
exempt_setback_ft: float
@dataclass(frozen=True)
class ProposedChange:
site_id: str # e.g. TWR-8842
zoning_code: str # e.g. MUN-4A-RES
tower_height_ft: float
setback_ft: float
def evaluate_precedence(change, baseline, variance, exemption, evaluated_on):
if change.tower_height_ft <= 0 or change.setback_ft < 0:
raise VariancePrecedenceError(f"{change.site_id}: non-physical proposed geometry")
if variance is not None and variance.expiry < evaluated_on:
raise VariancePrecedenceError(f"{change.site_id}: expired variance {variance.variance_id} reached evaluation")
height_ok = change.tower_height_ft <= baseline.max_height_ft
setback_ok = change.setback_ft >= baseline.min_setback_ft
if height_ok and setback_ok:
return Verdict.APPROVED_BASELINE, f"within {baseline.zoning_code} cap and setback"
if variance is not None:
height_cov = height_ok or (variance.covers_height and change.tower_height_ft <= variance.granted_height_ft)
setback_cov = setback_ok or (variance.covers_setback and change.setback_ft >= variance.granted_setback_ft)
if height_cov and setback_cov:
return Verdict.APPROVED_VARIANCE, f"variance {variance.variance_id} overrides baseline"
if exemption is not None:
height_gf = height_ok or change.tower_height_ft <= exemption.exempt_height_ft
setback_gf = setback_ok or change.setback_ft >= exemption.exempt_setback_ft
if height_gf and setback_gf:
return Verdict.APPROVED_GRANDFATHERED, f"grandfathered under permit {exemption.permit_id}"
return Verdict.DENIED, "no baseline compliance, active variance, or grandfather permit covers the gap"
def seal_verdict(change, baseline, verdict, reason, ts):
payload = json.dumps(
{"site": change.site_id, "zoning": baseline.zoning_code, "ordinance_version": baseline.version,
"verdict": verdict.value, "reason": reason, "evaluated_at": ts},
sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def decide(change, baseline, variance=None, exemption=None):
ts = datetime.now(timezone.utc)
verdict, reason = evaluate_precedence(change, baseline, variance, exemption, ts.date())
digest = seal_verdict(change, baseline, verdict, reason, ts.isoformat())
logger.info("PRECEDENCE | %s | %s | %s | audit=%s", change.site_id, baseline.zoning_code, verdict.value, digest[:12])
return {"site_id": change.site_id, "verdict": verdict.value, "reason": reason,
"ordinance_version": baseline.version, "evaluated_at": ts.isoformat(), "audit_hash": digest}
if __name__ == "__main__":
baseline = OrdinanceBaseline(zoning_code="MUN-4A-RES", max_height_ft=160.0, min_setback_ft=50.0, version="2026.2")
variance = Variance(variance_id="VAR-2214", covers_height=True, covers_setback=False,
granted_height_ft=180.0, granted_setback_ft=0.0, expiry=date(2027, 6, 1))
change = ProposedChange(site_id="TWR-8842", zoning_code="MUN-4A-RES", tower_height_ft=172.0, setback_ft=55.0)
print(json.dumps(decide(change, baseline, variance, exemption=None), indent=2))
Verification & Expected Output
TWR-8842 proposes a 172 ft tower against a 160 ft MUN-4A-RES cap, with a 55 ft setback that already clears the 50 ft minimum. Baseline alone denies the height, but VAR-2214 covers height up to 180 ft and hasn’t expired, so the precedence chain approves it on the variance:
2026-03-11 10:02:47 | INFO | PRECEDENCE | TWR-8842 | MUN-4A-RES | APPROVED_VARIANCE | audit=7c1e9f2a5b06
{
"site_id": "TWR-8842",
"verdict": "APPROVED_VARIANCE",
"reason": "variance VAR-2214 overrides baseline",
"ordinance_version": "2026.2",
"evaluated_at": "2026-03-11T10:02:47.201933+00:00",
"audit_hash": "7c1e9f2a5b06d4e1c8a207f9b3d6e5019fb84c3a7d0169e2f5a83c6b0e2d4a71"
}
To assert precedence rather than a single outcome, flip the inputs and check each branch independently. Dropping the variance (variance=None) against the same change must return DENIED_NO_PRECEDENT_APPLIES, because nothing else covers the height gap. Passing a GrandfatherExemption(permit_id="CUP-1990-07", exempt_height_ft=175.0, exempt_setback_ft=50.0) with variance=None must return APPROVED_GRANDFATHERED. Setting covers_height=False on the variance while everything else stays the same must fall through to the exemption or to a denial — a variance scoped to the wrong dimension must never be allowed to cover a gap it wasn’t granted for. And passing a Variance whose expiry is earlier than the evaluation date must raise VariancePrecedenceError, not silently fall through to baseline — an expired record reaching the resolver is a data problem, not a compliance answer. A failure that surfaces as VariancePrecedenceError naming the site means either the geometry is corrupt or an expired variance escaped upstream filtering; the fix is in the feed, not in loosening this check.
Gotchas & Edge Cases
- A variance that covers the wrong dimension is not a variance at all for this purpose. A grant scoped to height cannot rescue a setback violation, and treating
covers_heightorcovers_setbackas interchangeable — or worse, defaulting both toTruebecause “there’s a variance on file” — silently approves violations the municipality never authorized. Keep the scope booleans explicit and require both facts to be verified independently, one againstgranted_height_ftand one againstgranted_setback_ft. - Expiry must be checked against the evaluation date, not the filing date. A variance granted in 2024 with a three-year term is still active today, but the same record re-evaluated after its
expiryhas passed must not silently fall back to baseline as if the variance never existed — that fallback hides the fact that the tower’s compliance status changed the day the variance lapsed. This build treats an expired variance reaching the resolver as an error precisely so that lapse gets caught and flagged rather than absorbed quietly into a different verdict. - Grandfathering and variance are not interchangeable exceptions, and the order between them matters. A grandfather exemption reflects a permit issued before the current ordinance existed; a variance is a deliberate, current-day override. Checking grandfathering before variance can let a decades-old permit silently override a newer, more specific variance decision — including one that was denied. Keep the order fixed as baseline, then variance, then grandfather, and treat any proposal to reorder it as a policy change requiring the same review as changing the ordinance itself.
FAQ
Why check baseline compliance first instead of checking the variance first?
Because most proposals never need a variance or an exemption at all, and testing baseline first keeps the reason attached to the actual fact that resolved the case. If a proposal already fits the cap and setback, returning APPROVED_VARIANCE or APPROVED_GRANDFATHERED would be misleading even if a variance happens to be on file — the record should say the proposal succeeded on its own terms, reserving the override verdicts for the cases where they were actually load-bearing.
What happens if both a variance and a grandfather exemption could cover the same gap?
The variance wins, because it reflects the most recent authorization decision made about that specific site. This build never reaches the grandfather check once a scoped, unexpired variance already covers the violated dimension — evaluate_precedence returns on the first branch that resolves the gap. Keep both records in the audit trail regardless of which one decided the verdict, so a later review can see that an exemption existed even though it wasn’t the deciding factor.
Should the resolver ever query a live ordinance feed instead of a passed-in baseline?
No — pass in a resolved OrdinanceBaseline, Variance, and GrandfatherExemption that were already fetched and validated, the same separation of concerns used across Telecom Tower Compliance Architecture & Data Mapping. Keeping the resolver a pure function of its inputs is what makes the audit hash meaningful: re-running decide against the same recorded baseline, variance, and exemption must reproduce the same verdict, which is not guaranteed if the function reaches out to a feed that can change between the original decision and a later re-check.
Related
- Up to the parent topic: Zoning Rule Engine Design
- Sibling implementation: Building Zoning Compliance Rule Engines in Python
- The full architecture: Telecom Tower Compliance Architecture & Data Mapping