Computing effective standards when three overlays apply

Parcel 0714-22-1 is zoned R-1 and sits inside three overlays: airport height, historic preservation, and wellhead protection. A client asks a simple question — can they build a 32-foot duplex with a small fuel store? — and answering it correctly requires resolving eleven standards across four sources, then being able to show which rule produced each one. This guide works that resolution end to end, because the composition model in overlay district modeling is easiest to get wrong in the arithmetic rather than in the design.

Diagnosis: laying out the inputs before resolving anything jump to heading

Write the inputs down first. Most resolution errors are not logic errors; they are a standard read from the wrong source because nobody enumerated the sources.

Where each of eight standards ends up coming from An eight-row by four-column matrix tracing each standard to its source. Rows are permitted uses, height, front setback, side setback, rear setback, floor area ratio, lot coverage and procedural requirements. Columns are the base R-1 value, what the airport height overlay contributes, what the wellhead overlay contributes, and the resolved value with its source. Four of the eight standards are untouched by any overlay and still come from the base, which is the normal case. Where each of eight standards ends up coming from base R-1 AH contributes WP contributes resolved (source) permitted uses 6 uses removes 2 4 (base −WP) height_ft 35 ft replaces → 28 28 ft (AH) front_setback_ft 25 ft 25 ft (base) side_setback_ft 8 ft 8 ft (base) rear_setback_ft 20 ft 20 ft (base) far 0.50 0.50 (base) lot_coverage 40% restricts → 30% 30% (WP) requirements none facade review (HP) base value overlay contribution resolved silent
Half the standards are still the base's, which is what a resolver should look like: overlays touch a minority of standards. Height comes from a replacement, coverage from a restriction that happened to be stricter, and uses from a removal — three different mechanisms in one parcel.
Source Kind Contributes
R-1 (base) 6 permitted uses; height 35 ft; front 25 ft, side 8 ft, rear 20 ft; FAR 0.50; coverage 40%
AH — airport height replace height 28 ft
HP — historic preservation procedural facade review; no numeric standard
WP — wellhead protection restrict removes fuel storage and auto repair; coverage 30%

Two observations decide the whole resolution. AH replaces height, so the base’s 35 feet is superseded rather than compared — a restrict would have taken the stricter of the two, which happens to be the same number here and would not be in general. And WP restricts coverage, so 30% and 40% are compared and the stricter wins, which for coverage means the lower value.

BASE = Base(
    code="R-1",
    uses={"single_family", "duplex", "accessory_dwelling", "home_office",
          "fuel_storage", "auto_repair"},
    numeric={"height_ft": 35.0, "front_setback_ft": 25.0, "side_setback_ft": 8.0,
             "rear_setback_ft": 20.0, "far": 0.50, "lot_coverage": 0.40},
)

OVERLAYS = [
    Overlay("AH", Kind.REPLACE, precedence=1, ordinance_ref="§14-207",
            numeric={"height_ft": 28.0}),
    Overlay("HP", Kind.PROCEDURAL, precedence=2, ordinance_ref="§12-118",
            requirements=("facade review by the historic commission",)),
    Overlay("WP", Kind.RESTRICT, precedence=3, ordinance_ref="§18-402",
            numeric={"lot_coverage": 0.30},
            uses_removed=frozenset({"fuel_storage", "auto_repair"})),
]

Step-by-step implementation jump to heading

1. Resolve in precedence order, recording a citation per standard jump to heading

def resolve(base, overlays):
    out = Resolved(numeric=dict(base.numeric), uses=set(base.uses),
                   requirements=[], provenance={})
    for k in base.numeric:
        out.provenance[k] = f"base:{base.code}"
    out.provenance["uses"] = f"base:{base.code}"

    for ov in sorted(overlays, key=lambda o: o.precedence):
        if ov.kind is Kind.PROCEDURAL:
            out.requirements.extend(ov.requirements)
            out.provenance["requirements"] = (
                out.provenance.get("requirements", "") + f" {ov.code}({ov.ordinance_ref})"
            ).strip()
        elif ov.kind is Kind.REPLACE:
            for k, v in ov.numeric.items():
                out.numeric[k] = v
                out.provenance[k] = f"{ov.code}:replace({ov.ordinance_ref})"
        elif ov.kind is Kind.RESTRICT:
            for k, v in ov.numeric.items():
                chosen = stricter(k, out.numeric[k], v)
                if chosen != out.numeric[k]:
                    out.numeric[k] = chosen
                    out.provenance[k] = f"{ov.code}:restrict({ov.ordinance_ref})"
            if ov.uses_removed:
                removed = out.uses & ov.uses_removed
                out.uses -= ov.uses_removed
                if removed:
                    out.provenance["uses"] += f" −{ov.code}({ov.ordinance_ref})"
    return out

2. Read the resolved result jump to heading

Running that on the inputs above gives:

Standard Value Provenance
permitted uses 4 base:R-1 −WP(§18-402)
height_ft 28.0 AH:replace(§14-207)
front_setback_ft 25.0 base:R-1
side_setback_ft 8.0 base:R-1
rear_setback_ft 20.0 base:R-1
far 0.50 base:R-1
lot_coverage 0.30 WP:restrict(§18-402)
requirements facade review HP(§12-118)

Four of the eight rows still come from the base, which is the normal case and is worth noticing: overlays touch a minority of standards, and a resolver that rewrote everything would be hiding that.

3. Answer the client’s actual question jump to heading

def can_build(proposal, resolved) -> tuple[bool, list[str]]:
    reasons = []
    if proposal.use not in resolved.uses:
        reasons.append(f"use {proposal.use!r} is not permitted "
                       f"({resolved.cite('uses')})")
    if proposal.height_ft > resolved.numeric["height_ft"]:
        reasons.append(f"height {proposal.height_ft} ft exceeds "
                       f"{resolved.numeric['height_ft']} ft "
                       f"({resolved.cite('height_ft')})")
    if proposal.coverage > resolved.numeric["lot_coverage"]:
        reasons.append(f"coverage {proposal.coverage:.0%} exceeds "
                       f"{resolved.numeric['lot_coverage']:.0%} "
                       f"({resolved.cite('lot_coverage')})")
    return (not reasons), reasons + [f"also required: {r}" for r in resolved.requirements]

For the 32-foot duplex with fuel storage the answer is no, for two independent reasons: the height exceeds the airport overlay’s 28-foot cap (AH:§14-207), and fuel storage was removed by the wellhead overlay (WP:§18-402). The duplex use itself is fine, and a facade review would be required regardless. That is four distinct facts, each traceable to a section — which is what makes the answer defensible rather than merely correct.

The proposal, tested standard by standard A four-row by three-column matrix testing a proposal for a 32-foot duplex with fuel storage on 28 per cent lot coverage. Rows are the use, the height, the coverage and the procedural requirement. Columns are what the proposal asks for, what the resolved standard permits, and the verdict with its citation. The use fails because the wellhead overlay removed fuel storage. The height fails because the airport overlay caps at 28 feet. The coverage passes. A facade review is required regardless. The proposal, tested standard by standard proposal asks resolved standard verdict (citation) use: fuel storage fuel storage not in the 4 permitted uses FAIL (WP §18-402) height: 32 ft 32 ft max 28 ft FAIL (AH §14-207) coverage: 28% 28% max 30% PASS (WP §18-402) facade review not planned required REQUIRED (HP §12-118) passes condition fails the ask
Two independent failures, each traceable to a section, plus one requirement that applies whatever else is decided. That is four facts a client can act on — and a reviewer can check — rather than a single unexplained "no".

4. Keep the resolution as an artifact jump to heading

def resolution_record(parcel_id, base, overlays, resolved, as_of):
    return {
        "parcel_id": parcel_id,
        "as_of": as_of.isoformat(),
        "base_code": base.code,
        "overlay_codes": sorted(o.code for o in overlays),
        "numeric": resolved.numeric,
        "uses": sorted(resolved.uses),
        "requirements": resolved.requirements,
        "provenance": resolved.provenance,       # the part an auditor reads
        "resolver_version": RESOLVER_VERSION,    # so a rule change is attributable
    }

Verification & testing jump to heading

def test_worked_example():
    r = resolve(BASE, OVERLAYS)
    assert r.numeric["height_ft"] == 28.0
    assert r.cite("height_ft") == "AH:replace(§14-207)"
    assert r.numeric["lot_coverage"] == 0.30
    assert r.cite("lot_coverage") == "WP:restrict(§18-402)"
    assert r.numeric["front_setback_ft"] == 25.0
    assert r.cite("front_setback_ft") == "base:R-1"     # untouched by any overlay
    assert r.uses == {"single_family", "duplex", "accessory_dwelling", "home_office"}
    assert r.requirements == ["facade review by the historic commission"]


def test_order_of_the_overlay_list_does_not_matter():
    import itertools
    results = [resolve(BASE, list(p)) for p in itertools.permutations(OVERLAYS)]
    assert all(r.numeric == results[0].numeric for r in results)
    assert all(r.uses == results[0].uses for r in results)


def test_every_value_has_a_citation():
    r = resolve(BASE, OVERLAYS)
    for standard in r.numeric:
        assert r.cite(standard) != "unresolved"


def test_the_clients_question():
    r = resolve(BASE, OVERLAYS)
    ok, reasons = can_build(Proposal(use="fuel_storage", height_ft=32, coverage=0.28), r)
    assert not ok
    assert any("height 32" in x for x in reasons)
    assert any("fuel_storage" in x for x in reasons)

The permutation test is the one worth keeping permanently: it proves the resolution does not depend on the order overlays arrive in, which is exactly the property that fails first when somebody adds a fourth overlay kind.

Failure recovery jump to heading

A resolved value with no citation. The provenance map has a gap, which means a standard was written by a path that did not record its source. Treat every answer derived from that resolver version as unverifiable and re-resolve after fixing the gap — an uncited number cannot be defended even if it happens to be right.

Three ways a resolution goes wrong after the fact A three-row by three-column matrix of post-hoc resolution problems. Rows are a resolved value with no citation, a standard that changed after an ordinance amendment, and a fourth overlay arriving on the parcel. Columns are what it means, the repair, and whether previously issued answers remain valid. An uncited value is unverifiable and every answer from that resolver version is suspect. An amendment does not invalidate earlier answers, which were correct as of their date. Three ways a resolution goes wrong after the fact what it means repair earlier answers A resolved value with no citation unverifiable output fix the gap, re-resolve all all suspect An ordinance amendment landed the registry is stale edit registry, re-resolve correct as of their date A fourth overlay on the parcel new precedence question register kind + precedence unaffected clean needs a decision serious
The middle row is the one that vindicates computing on demand: an ordinance amendment needs a registry edit and a re-resolve, with no data migration — and answers already issued stay correct as of their date, which the as_of stamp is there to show.

A standard that changed after an ordinance amendment. Because nothing is stored resolved, the fix is to update the registry entry and re-resolve; no data migration is needed. Any compliance answer already issued under the old standard remains correct as of its date, which the as_of and resolver_version fields are there to show.

A fourth overlay arriving on the parcel. Resolution handles it without code changes provided its kind and precedence are registered. If it is a second replace on a standard another overlay already replaces, the conflict machinery in resolving conflicting overlay stacking rules takes over.

Frequently asked questions jump to heading

Does the order of the overlay list matter?

Not for the result, which is the property the permutation test protects, but only because precedence is declared on each overlay rather than taken from list position. Sorting by declared precedence before applying is what makes the resolution independent of how the source happened to return the overlay array.

Why does the coverage end up at 30% and the height at 28 ft by different routes?

Because the overlays act differently. WP restricts coverage, so 30% and the base’s 40% are compared and the stricter wins. AH replaces height, so the base’s 35 ft is superseded outright rather than compared. Here both routes happen to produce the value from the overlay; with a base height of 24 ft, the replace would have raised the limit to 28 while a restrict would have kept 24.

Should the resolved record be stored per parcel?

Store it as an artifact of an answer given, not as the parcel’s state. When you tell a client something, record the resolution with its as_of date and resolver version so the advice is reproducible. The parcel itself continues to hold only the base code and overlay set, so an ordinance amendment does not require rewriting parcels.

What if a procedural overlay also has a numeric standard?

Then it is not purely procedural, and its registry entry is wrong. Split it: register the numeric part with the kind that describes what it does to the standard, and keep the procedural requirement alongside. A single overlay doing two kinds of thing is the case where a resolver silently applies only one of them.