Building Zoning Compliance Rule Engines in Python

A carrier wants to raise the antenna on a monopole by five feet, swap in a heavier RF array, or shift the compound fence to fit a new cabinet — and someone has to answer, before the crew mobilizes, whether the municipality will allow it. The facts that decide it are scattered: a height cap in a zoning ordinance, a setback measured off a parcel line, a grandfathering clause from the original conditional-use permit, and maybe a variance already on file. Answering that by hand across a multi-jurisdiction portfolio is where deployment latency and regulatory exposure creep in, because two reviewers reading the same ordinance reach two different verdicts. This page is the hands-on build guide for the decision core inside the Zoning Rule Engine Design: a small, runnable Python module that takes one proposed modification plus the governing ordinance, applies a fixed precedence order, and returns a deterministic verdict sealed with a tamper-evident audit hash. Same inputs, same answer, every time — and a digest a permit reviewer can re-compute to prove the record was never altered.

Prerequisites & Context

Before running the code below, have the following in place:

  • Python 3.10+ and the standard library only. The engine leans on dataclasses, enum, hashlib, and json — no third-party packages. Frozen dataclasses give you an immutable evaluation context so a rule can never mutate the record it is judging.
  • A canonical zoning code per jurisdiction. Each ordinance is keyed to a MUN-XX-XXX zoning designation (for example MUN-4A-RES for a 4A residential overlay) and an ordinance revision string, so a verdict always cites the exact version of the rule that produced it. Where those canonical fields come from is defined upstream in Lease Taxonomy Standardization.
  • A canonical site identifier per tower. Key every modification to an antenna structure — a TWR-#### site ID and its FCC Antenna Structure Registration (ASR) number — so a verdict cross-references the correct mast and its federal registration downstream.
  • Agreement on precedence. Decide up front which rule wins when several apply. This build encodes the common order: an approved variance overrides dimensional limits, a setback violation is an outright denial, and only then does the height cap apply — with grandfathering as the last escape hatch. Changing that order changes the verdict, so it belongs in code review, not in a reviewer’s head.

In production the pure decision core below is wrapped by two concerns owned by sibling pages: sensitive lease and landlord fields are tokenized before evaluation per Security Boundary Configuration, and when a municipal ordinance feed is unreachable the request is held rather than guessed, following Fallback Routing Protocols. This page stays focused on getting the decision itself correct and auditable.

Step-by-Step Implementation

Each step maps to a specific compliance concern, not just a coding convenience.

Step 1 — Model the evaluation context as immutable data. Represent the Ordinance (zoning code, max height, min setback, version) and the proposed Modification (site ID, ASR number, proposed height, setback, grandfathered flag, variance flag) as frozen dataclasses. Immutability is what guarantees the record that produced a verdict is byte-for-byte the record you hash.

Step 2 — Reject non-physical geometry before judging it. A proposed height at or below zero or a negative setback is corrupt input, not a compliance question. Raise a custom ZoningRuleError so a bad record fails loudly at the door instead of returning a confident but meaningless verdict.

Step 3 — Apply precedence in a fixed order. Check the approved variance first — it overrides dimensional limits. Then test the setback, because a setback violation is a hard denial no height allowance can rescue. Only then compare proposed height against the cap, and let the grandfathered flag convert an over-cap structure into an approved legacy exception.

Step 4 — Return a typed verdict, never a bare string. Express every outcome as a Verdict enum member (APPROVED_COMPLIANT, APPROVED_VARIANCE, APPROVED_GRANDFATHERED, DENIED_HEIGHT_EXCEEDED, DENIED_SETBACK_VIOLATION). A closed set of verdicts is what lets downstream dashboards and renewal workflows branch reliably.

Step 5 — Seal the decision with an audit hash. Serialise the site ID, ASR number, zoning code, ordinance version, verdict, and evaluation timestamp with sorted keys and hash the result with hashlib.sha256. Re-computing that digest later proves the verdict presented today is exactly the one the engine produced, against exactly that ordinance revision.

Step 6 — Emit a structured log line per decision. Log the site, zoning code, verdict, and the digest prefix so an operator can trace any tower’s evaluation without opening the database.

Complete Runnable Example

The module below implements every step with realistic telecom identifiers, stdlib structured logging, a custom exception, and a SHA-256 audit hash over the sealed decision. The diagram traces one modification through the precedence order, embedded in its production wrapper, before the code.

Zoning rule engine precedence ladder and audit sealing An immutable evaluation context is tokenized at the security boundary (a dependency failure is held in a manual audit queue), then walked through a fixed precedence ladder — geometry validity, then variance on file, then setback minimum, then the height cap with grandfathering as the final exception — producing one of five typed verdicts, each sealed by a SHA-256 audit hash over the site, ASR number, zoning code, ordinance version, verdict, and timestamp. valid no variance within setback over cap invalid on file under minimum within cap grandfathered not grandfathered seals every verdict Evaluation context immutable record Security boundary tokenize sensitive fields Geometry valid? Variance on file? Setback ≥ minimum? Height ≤ cap? Grandfathered? Manual audit queue held · not guessed ZoningRuleError raised · no verdict Approved APPROVED_VARIANCE Denied DENIED_SETBACK_VIOLATION Approved APPROVED_COMPLIANT Approved APPROVED_GRANDFATHERED Denied DENIED_HEIGHT_EXCEEDED SHA-256 audit hash sha256(site · asr · zoning · version · verdict · ts)

Figure: the fixed precedence ladder — geometry, variance, setback, then the height cap with grandfathering as the last exception — wrapped by the security boundary and its manual-audit fallback, with every one of the five typed verdicts sealed by a SHA-256 audit hash.

python
# Evaluate a proposed telecom tower modification against a municipal zoning ordinance.
import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum

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


class ZoningRuleError(Exception):
    """Raised when an evaluation context is malformed or internally inconsistent."""


class Verdict(str, Enum):
    APPROVED_COMPLIANT = "APPROVED_COMPLIANT"
    APPROVED_VARIANCE = "APPROVED_VARIANCE"
    APPROVED_GRANDFATHERED = "APPROVED_GRANDFATHERED"
    DENIED_HEIGHT = "DENIED_HEIGHT_EXCEEDED"
    DENIED_SETBACK = "DENIED_SETBACK_VIOLATION"


@dataclass(frozen=True)
class Ordinance:
    zoning_code: str        # canonical designation, e.g. MUN-4A-RES
    max_height_ft: float
    min_setback_ft: float
    version: str            # ordinance revision, e.g. 2026.1


@dataclass(frozen=True)
class Modification:
    site_id: str            # e.g. TWR-8842
    asr_number: str         # FCC Antenna Structure Registration number
    proposed_height_ft: float
    setback_ft: float
    grandfathered: bool
    variance_on_file: bool


def evaluate(mod: Modification, ordinance: Ordinance) -> Verdict:
    if mod.proposed_height_ft <= 0 or mod.setback_ft < 0:
        raise ZoningRuleError(f"{mod.site_id}: non-physical geometry in evaluation context")
    # Precedence: an approved variance overrides dimensional limits.
    if mod.variance_on_file:
        return Verdict.APPROVED_VARIANCE
    if mod.setback_ft < ordinance.min_setback_ft:
        return Verdict.DENIED_SETBACK
    if mod.proposed_height_ft > ordinance.max_height_ft:
        return Verdict.APPROVED_GRANDFATHERED if mod.grandfathered else Verdict.DENIED_HEIGHT
    return Verdict.APPROVED_COMPLIANT


def audit_hash(mod: Modification, ordinance: Ordinance, verdict: Verdict, ts: str) -> str:
    payload = json.dumps(
        {"site": mod.site_id, "asr": mod.asr_number, "zoning": ordinance.zoning_code,
         "ordinance_version": ordinance.version, "verdict": verdict.value, "evaluated_at": ts},
        sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def decide(mod: Modification, ordinance: Ordinance) -> dict:
    ts = datetime.now(timezone.utc).isoformat()
    verdict = evaluate(mod, ordinance)
    digest = audit_hash(mod, ordinance, verdict, ts)
    logger.info("ZONING | %s | %s | %s | audit=%s",
                mod.site_id, ordinance.zoning_code, verdict.value, digest[:12])
    return {"site_id": mod.site_id, "verdict": verdict.value,
            "evaluated_at": ts, "audit_hash": digest}


if __name__ == "__main__":
    ordinance = Ordinance(zoning_code="MUN-4A-RES", max_height_ft=160.0,
                          min_setback_ft=50.0, version="2026.1")
    modification = Modification(site_id="TWR-8842", asr_number="1290321",
                               proposed_height_ft=165.0, setback_ft=52.0,
                               grandfathered=False, variance_on_file=False)
    print(json.dumps(decide(modification, ordinance), indent=2))

Verification & Expected Output

The TWR-8842 example proposes 165 ft against a 160 ft cap in a MUN-4A-RES overlay, with no variance and no grandfathering, so the engine denies it on height and seals the decision:

text
2026-02-20 09:14:03 | INFO | ZONING | TWR-8842 | MUN-4A-RES | DENIED_HEIGHT_EXCEEDED | audit=a4f19c7b2e08
{
  "site_id": "TWR-8842",
  "verdict": "DENIED_HEIGHT_EXCEEDED",
  "evaluated_at": "2026-02-20T09:14:03.512847+00:00",
  "audit_hash": "a4f19c7b2e08d3c6a1905f8e2b7c4d9013fa62e8c5b1704e9d2a86f31c0b7e54"
}

To assert behaviour in a test, check the precedence invariants rather than a single case. Flipping variance_on_file=True on the same record must return APPROVED_VARIANCE even though the height still exceeds the cap; setting grandfathered=True must return APPROVED_GRANDFATHERED; and dropping setback_ft to 48.0 must return DENIED_SETBACK_VIOLATION regardless of height. Determinism has its own assertion: audit_hash(mod, ord, v, ts) == audit_hash(mod, ord, v, ts) for a fixed timestamp confirms the digest is reproducible. A failure looks like a ZoningRuleError naming the site — that means a corrupt record (a zero or negative dimension) reached the engine, and the fix is upstream validation, not a looser rule.

Gotchas & Edge Cases

  • Ordinance version drift. A verdict is only meaningful against the exact ordinance revision it was evaluated on. If a municipality amends a height cap and you re-run a stored modification against the new version, the audit hash changes and the two decisions no longer match — that is correct behaviour, not a bug. Always store the ordinance version alongside the verdict so an auditor can tell a re-evaluation apart from a tampered record, and never mutate an Ordinance in place; construct a new one for the new revision.
  • Precedence is a policy decision, not a default. This build denies on setback before it ever considers grandfathering, because in most jurisdictions a legacy structure that encroaches a required setback still cannot expand. Some municipalities invert that. The order of the if branches in evaluate is the regulation, so any reordering must be reviewed as a compliance change and re-tested against known cases — a silent swap can approve a structure that should have been denied.
  • Unit and datum mismatches. Heights arrive in feet from some feeds and meters from others, and setbacks may be measured from the parcel line in one dataset and the lease boundary in another. The engine trusts its inputs, so a value of 50 that is really meters against a 160-foot cap passes a check it should fail. Normalise units and the setback datum during canonicalization upstream, and keep the engine’s fields strictly single-unit so a mismatch is caught before evaluation rather than hidden inside an approval.

FAQ

Why hash the decision instead of just logging the verdict?

A log line records what the engine said; an audit hash proves the inputs that produced it were never altered. Because the digest is computed over the site ID, ASR number, zoning code, ordinance version, verdict, and timestamp with sorted keys, any later edit to any of those fields yields a different hash. When a permit reviewer or an FCC auditor asks whether the approval on file is the one the engine actually issued, they re-compute the SHA-256 over the stored record and compare — a match is cryptographic evidence of integrity that a plain log cannot give.

How do I evaluate one tower against several overlapping jurisdictions?

Run the engine once per governing Ordinance — municipal overlay, county, and any special district — and combine the verdicts under a most-restrictive rule: any DENIED_* result denies the modification overall, and the tower is approved only if every jurisdiction returns an approving verdict. Keep each jurisdiction’s audit hash separately so the record shows exactly which authority produced which decision. This mirrors how the parent Zoning Rule Engine Design resolves layered municipal, county, and federal precedence.

What happens when the ordinance data itself is unavailable?

The engine never guesses a missing rule. If the municipal ordinance feed is down, the request is held and routed to a manual review queue rather than evaluated against stale or absent limits — the behaviour defined in Fallback Routing Protocols. Holding is the safe default because an approval issued against the wrong height cap is a regulatory liability, whereas a queued request only costs time. Cached ordinance snapshots can serve reads during short outages, but every fallback decision is tagged so it can be re-evaluated once the authoritative feed returns.

Related pages