Resolving conflicting overlay stacking rules
A parcel sits inside both an airport-height overlay capping height at 28 feet and a scenic-corridor overlay capping it at 32. Your resolver applies them in whatever order the source returned them, so the answer is 28 feet on Tuesday and 32 on Wednesday when the portal reorders its rows. Neither answer is wrong in isolation; what is wrong is that the pipeline has no rule, so the output depends on row order in somebody else’s database. This guide builds the rule — a precedence table sourced from the ordinance, a declared restrictiveness direction per standard, and an explicit refusal when both are silent. It extends the composition model in overlay district modeling.
Diagnosis: finding the conflicts you already have jump to heading
A conflict exists wherever two overlays that co-occur on at least one parcel both constrain the same standard. That is computable from the registry and the parcel data, and the list is usually short.
from collections import defaultdict
from itertools import combinations
def find_conflicts(parcels, registry):
"""Pairs of overlays that co-occur AND touch the same standard."""
co_occurring = set()
for overlays in parcels["overlay_codes"]:
for a, b in combinations(sorted(set(overlays)), 2):
co_occurring.add((a, b))
conflicts = []
for a, b in sorted(co_occurring):
oa, ob = registry[a], registry[b]
shared = set(oa.numeric) & set(ob.numeric)
for standard in sorted(shared):
if oa.numeric[standard] == ob.numeric[standard]:
continue # agree: not a conflict
conflicts.append({
"overlays": (a, b),
"standard": standard,
"values": (oa.numeric[standard], ob.numeric[standard]),
"kinds": (oa.kind, ob.kind),
"parcels": count_parcels_with_both(parcels, a, b),
# Two REPLACE overlays are the dangerous kind: order decides.
"order_dependent": oa.kind == "replace" and ob.kind == "replace",
})
return conflicts
Two kinds of conflict come out, and they are not equally serious.
min and four want max. Applying one rule to all eight silently relaxes every setback and lot-size minimum — a more permissive answer, which is the direction nobody double-checks. That is why the resolver refuses on a standard whose direction has not been declared.Both overlays restrict. These are safe once a direction is declared, because restriction composes: the stricter value wins regardless of the order they are applied in. min for a height cap, max for a setback.
Both overlays replace. These are genuinely order-dependent, and no amount of restrictiveness reasoning settles them — a replacement asserts a value rather than a bound. This is the set that needs a precedence table, and it is the set the order_dependent flag isolates.
Step-by-step implementation jump to heading
1. Declare a restrictiveness direction per standard, and refuse without one jump to heading
Restrictiveness is a property of the standard, not of the overlay, and getting the direction backwards is silent: a “stricter” setback computed with min produces a smaller setback, which is more permissive.
MORE_RESTRICTIVE_IS_LOWER = {
"height_ft", "far", "units_per_acre", "lot_coverage", "max_impervious_pct",
}
MORE_RESTRICTIVE_IS_HIGHER = {
"front_setback_ft", "side_setback_ft", "rear_setback_ft", "min_lot_area_sf",
"min_lot_width_ft", "parking_spaces_min", "min_open_space_pct",
}
def stricter(standard: str, a: float, b: float) -> float:
if standard in MORE_RESTRICTIVE_IS_LOWER:
return min(a, b)
if standard in MORE_RESTRICTIVE_IS_HIGHER:
return max(a, b)
raise ValueError(
f"{standard!r} has no declared restrictiveness direction. Add it to one of "
f"the two sets before any overlay is allowed to restrict it — guessing gets "
f"setbacks exactly backwards.")
2. Build the precedence table from the ordinance, with a citation jump to heading
from dataclasses import dataclass
@dataclass(frozen=True)
class Precedence:
jurisdiction: str
winner: str # overlay code
loser: str
standard: str | None # None = applies to every standard they share
authority: str # the ordinance section, or 'policy:most-restrictive'
decided_by: str # a person, for the policy cases
decided_on: str
class PrecedenceTable:
def __init__(self, rows: list[Precedence]):
self._by_pair = {}
for r in rows:
self._by_pair[(r.jurisdiction, r.winner, r.loser, r.standard)] = r
def resolve(self, jurisdiction, a, b, standard) -> Precedence | None:
for key in ((jurisdiction, a, b, standard), (jurisdiction, a, b, None),
(jurisdiction, b, a, standard), (jurisdiction, b, a, None)):
if key in self._by_pair:
return self._by_pair[key]
return None
3. Apply the ladder: ordinance, then policy, then refuse jump to heading
class UnresolvedConflict(Exception):
"""Two replacements, no ordinance rule, no policy. The pipeline must not pick."""
def resolve_pair(jurisdiction, oa, ob, standard, table, policy_most_restrictive=True):
"""Returns (value, citation). Never returns a value it cannot attribute."""
explicit = table.resolve(jurisdiction, oa.code, ob.code, standard)
if explicit is not None:
winner = oa if explicit.winner == oa.code else ob
return winner.numeric[standard], f"{winner.code}:{explicit.authority}"
if oa.kind == "restrict" and ob.kind == "restrict":
value = stricter(standard, oa.numeric[standard], ob.numeric[standard])
src = oa if value == oa.numeric[standard] else ob
return value, f"{src.code}:restrict(stricter of two)"
if policy_most_restrictive:
value = stricter(standard, oa.numeric[standard], ob.numeric[standard])
src = oa if value == oa.numeric[standard] else ob
# Recorded as a POLICY decision, not as an ordinance one. The distinction
# matters when somebody checks the answer against the code.
return value, f"{src.code}:policy:most-restrictive"
raise UnresolvedConflict(
f"{oa.code} and {ob.code} both replace {standard} "
f"({oa.numeric[standard]} vs {ob.numeric[standard]}) with no precedence rule")
The three rungs are ordered by authority, and the citation says which rung answered. “28 ft, from AH:§14-207” and “28 ft, from AH:policy:most-restrictive” are both usable answers and they are not the same claim — the second one is your organisation’s decision, not the city’s, and a reviewer is entitled to know that.
§14-310 can be checked against the code; one citing policy:most-restrictive cannot, because it is your decision — and a reviewer is entitled to know which they are looking at.4. Make the policy default explicit and reviewable jump to heading
Most-restrictive-wins is a defensible default and it is still a choice. Record it once, with an owner and a date, in the same registry as the ordinance-sourced rows — a default that lives only in code is a decision nobody can find later.
Verification & testing jump to heading
def test_two_restricting_overlays_are_order_independent():
a, b = overlay("AH", "restrict", height_ft=28), overlay("SC", "restrict", height_ft=32)
v1, _ = resolve_pair("larimer", a, b, "height_ft", EMPTY_TABLE)
v2, _ = resolve_pair("larimer", b, a, "height_ft", EMPTY_TABLE)
assert v1 == v2 == 28
def test_setback_direction_is_not_inverted():
a, b = overlay("A", "restrict", front_setback_ft=25), overlay("B", "restrict",
front_setback_ft=40)
v, _ = resolve_pair("larimer", a, b, "front_setback_ft", EMPTY_TABLE)
assert v == 40, "the LARGER setback is the stricter one"
def test_undeclared_standard_refuses():
a, b = overlay("A", "restrict", noise_db=55), overlay("B", "restrict", noise_db=60)
with pytest.raises(ValueError, match="restrictiveness direction"):
resolve_pair("larimer", a, b, "noise_db", EMPTY_TABLE)
def test_two_replacements_without_a_rule_raise():
a, b = overlay("AH", "replace", height_ft=28), overlay("SC", "replace", height_ft=32)
with pytest.raises(UnresolvedConflict):
resolve_pair("larimer", a, b, "height_ft", EMPTY_TABLE,
policy_most_restrictive=False)
def test_ordinance_precedence_beats_policy():
table = PrecedenceTable([Precedence("larimer", "SC", "AH", "height_ft",
"§14-310", "j.doe", "2026-03-02")])
a, b = overlay("AH", "replace", height_ft=28), overlay("SC", "replace", height_ft=32)
v, why = resolve_pair("larimer", a, b, "height_ft", table)
assert v == 32 and "§14-310" in why # the ordinance says the corridor wins
def test_no_conflict_is_order_dependent_in_production(parcels, registry, table):
"""The production gate: every order-dependent conflict must have a rule."""
unresolved = [c for c in find_conflicts(parcels, registry)
if c["order_dependent"]
and table.resolve("larimer", *c["overlays"], c["standard"]) is None]
assert not unresolved, f"{len(unresolved)} conflicts have no precedence rule"
The setback test is the one that catches the most damaging class of bug, because reversing the direction produces a smaller setback — a more permissive answer that looks entirely reasonable in the output.
Failure recovery jump to heading
Answers that changed when the portal reordered its rows. Every resolved standard produced before the precedence table existed is suspect for the conflicting pairs. Identify affected parcels from the conflict list, re-resolve, and re-derive downstream capacity and compliance results for those parcels only — the set is usually small and geographically clustered.
A direction declared backwards. Every parcel where that standard was restricted by an overlay has the wrong value, and in the permissive direction. Fix the set membership, re-resolve, and treat any compliance answer issued in the interim as needing re-issue.
A precedence rule that contradicts the ordinance. Correct the table row, keep the old row as superseded with its dates, and re-resolve. Keeping the superseded row is what lets you explain an answer given last quarter, rather than only the answer you would give now.
Frequently asked questions jump to heading
Why not always take the most restrictive value?
Because a replace overlay asserts a value rather than a bound, and the ordinance may deliberately relax a standard — a transit overlay raising a height limit is a real and common instrument. Most-restrictive-wins as a default is defensible; as a universal rule it silently overrides the intent of every permissive overlay in the code.
Where does precedence come from when the ordinance is silent?
From a recorded policy decision with an owner and a date, applied consistently, and cited as policy rather than as ordinance in the output. The important part is that the answer’s citation distinguishes the two, so a reviewer checking against the code knows which lines they will find there and which are yours.
Should the resolver ever raise rather than answer?
Yes, for two replacements with no rule and no policy, and for a standard with no declared restrictiveness direction. Both are cases where any value the resolver produced would be arbitrary, and an arbitrary number that enters a compliance decision is worse than a failure that gets somebody’s attention.
Is restrictiveness really a property of the standard?
Yes, and that is what makes a single min or max unsafe. A smaller height limit is stricter; a smaller setback is more permissive; a smaller minimum lot area is more permissive. The direction has to be declared per standard, which is why the resolver refuses on an unknown one rather than defaulting.
Related jump to heading
- Parent topic: Overlay District Modeling
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Composing base zoning and overlay districts — the overlay set this operates on
- Compliance Framework Integration — the consumer that needs the citation, not just the number
- Development Capacity & Buildable Area — where an inverted setback direction shows up as inflated capacity