Keeping tile styling in step with a taxonomy change

The taxonomy gains a class: RES-MED splits out of RES-LOW to describe the middle-density districts properly. No parcel geometry changed, no parcel’s actual zoning changed, so no change event fires and no tile is rebuilt — and 3 100 parcels now carry a class the style file has no rule for. They fall through to the fallback paint, which in most style files means a light grey nobody notices, or fill-opacity: 0, which means they vanish. The map is wrong in the way that is hardest to spot: not showing the wrong colour, but showing nothing. This guide couples the style to the taxonomy so the divergence fails a build instead of reaching a user, completing vector tile publishing for zoning maps.

Diagnosis: finding classes the style cannot paint jump to heading

The check is a set difference, and it is worth running as a gate rather than as an investigation.

What each artifact knows about a taxonomy change A four-row by three-column matrix of publishing artifacts. Rows are the parcel data, the tile pyramid, the state file, and the style file. Columns are whether a taxonomy version bump changes that artifact, whether the change feed reports it, and what a user sees if the artifact is not updated. Neither the parcel data nor the pyramid changes, so the change feed reports nothing, and a style file left behind renders the new classes as the fallback. What each artifact knows about a taxonomy change does it change? reported by the change feed? if left behind Parcel data no n/a nothing Tile pyramid no n/a nothing State file yes — new class indices no old classes shown Style file yes — new rules needed no new classes render as fallback unaffected must be updated the failure
The change feed is silent by construction, because no parcel changed — only the vocabulary describing it. That is why a taxonomy bump has to generate its own event, and why the style file is the artifact most likely to be left behind.
import json
import re


def style_classes(style_path) -> set[str]:
    """Extract every class the style names, from `match` and `case` expressions on the
    zoning class. Walks the whole expression tree, because a class can be named at any
    depth inside a nested expression."""
    style = json.loads(open(style_path).read())
    found = set()

    def walk(node, in_zoning_match=False):
        if isinstance(node, list):
            head = node[0] if node and isinstance(node[0], str) else None
            names_zoning = any(
                isinstance(x, list) and x[:1] == ["get"] and
                x[1:2] in (["zoning_class"], ["canonical_class"])
                or isinstance(x, list) and x[:1] == ["feature-state"] and
                x[1:2] == ["zoningClass"]
                for x in node)
            here = in_zoning_match or (head in ("match", "case") and names_zoning)
            for item in node:
                if here and isinstance(item, str) and re.match(r"^[A-Z][A-Z0-9-]+$", item):
                    found.add(item)
                walk(item, here)
        elif isinstance(node, dict):
            for v in node.values():
                walk(v, in_zoning_match)

    walk(style.get("layers", []))
    return found


def taxonomy_style_gap(taxonomy, style_path):
    styled = style_classes(style_path)
    declared = {c.code for c in taxonomy.classes}
    return {
        "taxonomy_version": taxonomy.version,
        "unstyled": sorted(declared - styled),      # render as the fallback: the bug
        "orphaned_rules": sorted(styled - declared),  # dead style rules
    }

Both directions matter. unstyled classes render as the fallback and are the failure this page is about. orphaned_rules are harmless to render and are a signal that a class was renamed — which usually means some parcels moved to a class nobody has styled either.

Step-by-step implementation jump to heading

1. Version the taxonomy, and make the style declare which version it targets jump to heading

{
  "version": 8,
  "name": "municipal-zoning",
  "metadata": {
    "taxonomy_version": "tax-v5",
    "taxonomy_digest": "9c1f4a2b7e08",
    "note": "Must match the pyramid manifest and the state file. The build fails if it does not."
  },
  "layers": [
    {
      "id": "parcels-fill",
      "source": "parcels",
      "source-layer": "parcels",
      "type": "fill",
      "paint": {
        "fill-color": [
          "match", ["feature-state", "zoningClass"],
          "RES-LOW-1", "#e0f2f1",
          "RES-LOW-2", "#c9e9e6",
          "RES-MED",   "#a9dcd8",
          "MIXED-USE", "#fde68a",
          "COMMERCIAL","#f7c07a",
          "INDUSTRIAL","#d9b8a0",
          "AGRICULTURAL","#d1fae5",
          "#ff00ff"
        ]
      }
    }
  ]
}

The fallback is deliberately #ff00ff. A conspicuous magenta is the single highest-value change in this whole page: an unstyled class becomes impossible to miss in review, where a light grey or a transparent fill is invisible. Ship the loud fallback in staging always, and in production too if you can stand it.

How long an unstyled class survives, by fallback colour Lollipop chart of how long an unstyled zoning class survives in production depending on the style's fallback colour. A transparent fallback survives about 90 days because the parcels simply vanish and nobody notices. A light grey fallback survives about 30 days. A mid grey survives about 9 days. A magenta fallback survives under a day, because it is impossible to miss in review. A dashed rule marks one day. How long an unstyled class survives, by fallback colour 0 20 40 60 80 100 1 day — caught in review magenta (#ff00ff) 0.5 days mid grey 9 days light grey 30 days transparent (fill-opacity 0) 90 days Days an unstyled class survives
The fallback colour is the highest-leverage line in the style file. A transparent or pale fallback means unstyled parcels are invisible and survive for months; magenta is caught in review before the build ships, which converts a silent production defect into a staging one.

2. Fail the build on a divergence jump to heading

class StyleTaxonomyMismatch(Exception):
    """The style cannot paint every class the taxonomy declares."""


def gate_style(taxonomy, style_path, manifest, state_payload):
    gap = taxonomy_style_gap(taxonomy, style_path)
    style = json.loads(open(style_path).read())
    declared = style.get("metadata", {}).get("taxonomy_version")

    problems = []
    if gap["unstyled"]:
        problems.append(f"{len(gap['unstyled'])} class(es) have no style rule and will "
                        f"render as the fallback: {', '.join(gap['unstyled'])}")
    if declared != taxonomy.version:
        problems.append(f"style targets {declared!r}, taxonomy is {taxonomy.version!r}")
    if manifest["taxonomy_version"] != taxonomy.version:
        problems.append(f"pyramid manifest targets {manifest['taxonomy_version']!r}")
    if state_payload["taxonomy_version"] != taxonomy.version:
        problems.append(f"state file targets {state_payload['taxonomy_version']!r}")

    if problems:
        raise StyleTaxonomyMismatch("; ".join(problems))

    if gap["orphaned_rules"]:
        # Not fatal — dead rules render nothing — but usually a rename in disguise.
        log.warning("style has %d rule(s) for classes no longer in the taxonomy: %s",
                    len(gap["orphaned_rules"]), ", ".join(gap["orphaned_rules"]))
    return gap

3. Emit a taxonomy change event, because nothing else will jump to heading

A taxonomy version bump changes what the map shows while no parcel changed, so the change feed is silent by construction. The publishing pipeline has to generate the event itself — the same class of internally generated event as a lapsing PUD.

def taxonomy_change_event(old, new, parcels) -> dict:
    """What a taxonomy bump actually did, in terms of parcels."""
    added = {c.code for c in new.classes} - {c.code for c in old.classes}
    removed = {c.code for c in old.classes} - {c.code for c in new.classes}
    remapped = {}
    for p in parcels:
        before = old.classify(p.jurisdiction, p.base_code)
        after = new.classify(p.jurisdiction, p.base_code)
        if before != after:
            remapped.setdefault((before, after), []).append(p.parcel_id)

    return {
        "kind": "taxonomy_version_change",
        "from": old.version, "to": new.version,
        "classes_added": sorted(added), "classes_removed": sorted(removed),
        "parcels_remapped": {f"{a}→{b}": len(v) for (a, b), v in remapped.items()},
        "total_parcels_remapped": sum(len(v) for v in remapped.values()),
        # No parcel's zoning changed — only our description of it.
        "regulatory_change": False,
    }

That regulatory_change: False field matters for how the event is communicated. A subscriber told “3 100 parcels changed class” will reasonably assume an ordinance did something; the honest message is that the classification improved and the underlying zoning is untouched.

4. Publish the three artifacts together jump to heading

Style, pyramid manifest and state file all carry the taxonomy version, so they must be published as one unit. Publishing the state file first leaves the style unable to paint the new classes; publishing the style first leaves it painting classes no parcel carries yet. Neither is catastrophic and both produce a visibly wrong map for as long as the window lasts, so make it atomic — a versioned directory containing all three, with a single pointer flipped at the end.

What a partial publish looks like to a user A three-row by three-column matrix of publish orders. Rows are publishing the state file first, the style first, and all three artifacts atomically. Columns are what is inconsistent during the window, what the user sees, and how long the window lasts. Publishing the state file first leaves the style unable to paint the new classes; publishing the style first leaves it painting classes no parcel carries. An atomic publish has no window at all. What a partial publish looks like to a user inconsistent during the window what the user sees window State file first style cannot paint new classes fallback colour one upload Style file first style paints unused classes old colours one upload All three atomically nothing correct throughout none safe brief visibly wrong
Both partial orders produce a visibly wrong map for however long the second upload takes. Publishing a versioned directory and flipping one pointer makes the window zero, which is the only reliable way to keep three interdependent artifacts consistent.

Verification & testing jump to heading

def test_every_taxonomy_class_has_a_style_rule():
    gap = taxonomy_style_gap(TAXONOMY, STYLE_PATH)
    assert not gap["unstyled"], (
        f"{gap['unstyled']} will render as the fallback — parcels carrying these "
        f"classes are effectively invisible")


def test_adding_a_class_without_styling_it_fails_the_build():
    tax = TAXONOMY.with_class("RES-MED")
    with pytest.raises(StyleTaxonomyMismatch, match="RES-MED"):
        gate_style(tax, STYLE_PATH, MANIFEST, STATE)


def test_all_three_artifacts_agree_on_the_version():
    gate_style(TAXONOMY, STYLE_PATH, MANIFEST, STATE)      # raises if any disagrees


def test_the_fallback_is_conspicuous():
    """A quiet fallback is why this bug reaches production. Assert it is loud."""
    fill = style_paint(STYLE_PATH, "parcels-fill", "fill-color")
    assert fill[-1].lower() in ("#ff00ff", "#f0f"), \
        "the fallback colour must be impossible to miss in review"


def test_taxonomy_bump_reports_no_regulatory_change():
    ev = taxonomy_change_event(TAX_V4, TAX_V5, PARCELS)
    assert ev["total_parcels_remapped"] > 0
    assert ev["regulatory_change"] is False


def test_orphaned_rules_warn_but_do_not_fail(caplog):
    style = add_rule(STYLE_PATH, "RES-OBSOLETE", "#123456")
    gate_style(TAXONOMY, style, MANIFEST, STATE)
    assert "no longer in the taxonomy" in caplog.text

Failure recovery jump to heading

Parcels rendering invisible in production. Add the missing style rules and republish the style; no tile rebuild is needed, because the geometry and the state file are both fine. Then add the gate, because this will recur on the next taxonomy change otherwise.

A class renamed and the old rule left in place. The orphaned-rule warning catches it, and the parcels are almost certainly unstyled under the new name. Fix both ends in one commit — remove the dead rule, add the new one — so the style’s class set matches the taxonomy exactly.

Three artifacts published out of step. Roll the pointer back to the previous versioned directory, which is why they are published as a unit. Then republish all three together. A partial rollback that leaves the state file ahead of the style reproduces the original symptom.

Frequently asked questions jump to heading

Why does a taxonomy change produce no change event?

Because no parcel’s zoning changed — only the vocabulary used to describe it. The change detector compares base codes, overlay sets, geometry and effective dates, none of which moved, so it correctly reports nothing. That is exactly why the publishing pipeline has to generate a taxonomy change event itself; it is a change in the map, not in the world.

Should the style fallback be invisible or conspicuous?

Conspicuous, and magenta is the traditional choice. A quiet grey fallback is why this bug reaches production: an unstyled class looks like an ordinary parcel or like nothing at all, and nobody reviewing the map notices. Magenta is noticed within seconds, which converts a silent production defect into a staging one.

Are orphaned style rules worth fixing?

They render nothing, so they are harmless in themselves — but they are usually a rename in disguise, which means some parcels have moved to a class that has no rule either. Treat an orphaned rule as a prompt to check the other direction, and remove it in the same commit that adds the replacement.

Why publish the style, manifest and state file together?

Because all three name the taxonomy version and any pair of them being out of step produces a visibly wrong map: a state file ahead of the style leaves new classes unpainted, and a style ahead of the state file paints classes nothing carries yet. Publishing a versioned directory and flipping one pointer makes the window zero rather than however long the second upload takes.