Versioning Permit Code Taxonomies for Annual Updates
Taxonomy versioning is the time-travel layer of the Core Architecture & Code Taxonomy for Municipal Permits: the discipline that lets a permitting platform answer “which edition of the code governs this application?” deterministically, for every file, across every annual revision cycle. Municipal building and zoning regulations turn over on predictable but rigid schedules — a state adopts a new model-code edition, an international body publishes updated chapters, a council passes a local amendment with its own effective date. Treating each of those transitions as a simple data overwrite is a critical operational failure: it corrupts historical records, misroutes active inspections, and destroys the audit trail compliance officers depend on. This guide shows Python automation builders how to model code editions as immutable, dated versions, ingest each annual release safely, and resolve the applicable edition for any submission without disturbing permits already in flight.
Permalink to this section Problem Statement and Scope
Without a deliberate versioning layer, the day a new code edition is adopted becomes the day every in-progress permit silently changes the rules it is judged against. A deck application filed in November under the prior International Residential Code suddenly fails validation in January because a single overwrite swapped the entire rule set underneath it. Re-inspections get scheduled against checkpoints that did not exist when the work was approved. And when a public-records request or a state audit asks “what code governed permit #2024-04417?”, there is no defensible answer because the only copy of the taxonomy is the current one.
Three groups feel that failure directly. Municipal clerks field angry calls from applicants whose approved plans were retroactively invalidated, with no way to see which edition their file was bound to. Python automation builders get paged when routing logic, fee tables, and inspection checklists all shift at once because they read a single mutable “current code” record. Compliance officers cannot certify that an application was reviewed against the regulations in force on its filing date — the grandfathering guarantee that most permitting statutes require.
The scope of this component is narrow and load-bearing: own the registry of code editions and the resolution of an edition for a given file. Its input is a published regulatory release (a new IRC/IBC edition or a local amendment) plus the metadata of an inbound or historical permit — chiefly its submission date and code family. Its output is an exact, immutable taxonomy version: the rule set, effective window, and routing priority that govern that file and nothing else. Everything below builds toward making that lookup deterministic, cache-friendly, and provable after the fact.
Permalink to this section Prerequisites
This component targets Python 3.10+ (the examples use match, X | Y unions, frozen=True slotted dataclasses, and date arithmetic). Install the runtime dependencies:
pip install "pydantic>=2.6" "packaging>=24.0" "structlog>=24.1" "sqlite-utils>=3.36"
You will also need:
- A durable, append-only store for the registry. Each edition is archived as a read-only snapshot, so the store must never update a published row in place. A single-file SQLite database (WAL mode) is the pragmatic default on a constrained municipal server; a small Postgres table with a
REVOKE UPDATEgrant works equally well where that infrastructure exists. - A canonical schema to normalize rules into. Every ingested edition must land in the same shape, which means resolution inherits the field definitions and validation rules from the JSON schema for building permits. Cache that schema on the host so versioning never depends on a live backend call.
- Authoritative effective dates for every release. The legal effective date — not the date your team ingested the PDF — drives every resolution. Source these from the adopting ordinance or state register, and reconcile state-versus-local precedence using the same mappings described in cross-referencing state and local building codes.
- Write access to a structured log sink so every version publish, resolution, and manual override is queryable during an audit.
Permalink to this section Model the Registry as Immutable, Dated Versions
Versioning starts with the data model. Give every edition two identifiers, because they answer two different questions. A calendar tag such as 2025-IRC communicates the public compliance boundary to applicants and plan reviewers. A semantic schema version such as 4.2.0 tracks the internal shape of the normalized rule set — a patch bump for a typo fix, a minor bump for an added optional field, a major bump for a structural migration that breaks consumers. Keep them separate: the public never sees the SemVer, and your migration tooling never parses the calendar tag.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from enum import Enum
from typing import Mapping
class Status(str, Enum):
DRAFT = "draft" # staged, not yet governing anything
ACTIVE = "active" # the edition new filings bind to
LEGACY_ACTIVE = "legacy_active" # still governs in-flight applications
DEPRECATED = "deprecated" # retired from production routing
@dataclass(frozen=True, slots=True)
class TaxonomyVersion:
"""One immutable, archived snapshot of a permit code taxonomy.
frozen=True makes the snapshot read-only in process; the durable store
must mirror that guarantee by refusing UPDATEs to a published row."""
calendar_tag: str # public compliance label, e.g. "2025-IRC"
code_family: str # "IRC", "IBC", "IFC", "local-zoning", ...
schema_version: str # SemVer of the normalized rule shape
effective_date: date # first day this edition governs new filings
deprecation_date: date | None # day it stops governing in-flight work
routing_priority: int # tie-breaker when windows overlap; higher wins
status: Status
rules: Mapping[str, dict] # rule_id -> normalized rule object
The non-negotiable rule is that a new annual cycle never overwrites an existing record. When the next edition is published, you append it as ACTIVE and transition the prior edition to LEGACY_ACTIVE — it keeps governing every application that was already in progress, preserving the grandfathered compliance pathway. Only when the last grandfathered file clears does the old edition move to DEPRECATED and leave production routing. The three metadata fields — effective_date, deprecation_date, and routing_priority — are what make that lifecycle expressible without ever mutating the rules themselves.
Permalink to this section Ingest the Annual Release and Generate a Diff
Translating a published code into a registry entry is a repeatable pipeline, not a one-off. Municipalities receive updated editions as PDF, HTML, or proprietary vendor exports, so ingestion reuses the same extraction discipline documented in automated permit ingestion and parsing workflows; when the source is a scanned ordinance, lean on parsing PDF permit applications with OCR and layout analysis to recover the text before normalization. Whatever the source format, the output is the same: discrete rule objects keyed by a stable rule_id, validated against the cached schema, with human-in-the-loop review for jurisdictional exceptions.
The single most valuable artifact ingestion produces is a diff against the outgoing edition. Reviewers and downstream owners need to see exactly which rules were added, removed, or changed, and which changes introduce new inspection checkpoints — not re-read the whole code.
from dataclasses import dataclass
@dataclass(slots=True)
class CodeDiff:
added: list[str]
removed: list[str]
changed: list[str]
new_checkpoints: list[str] # rules that add an inspection or review gate
def diff_editions(prev: TaxonomyVersion, nxt: TaxonomyVersion) -> CodeDiff:
"""Compare two normalized editions rule-by-rule so reviewers approve a
bounded changeset, not an opaque overwrite."""
prev_ids, next_ids = set(prev.rules), set(nxt.rules)
changed = [
rid for rid in prev_ids & next_ids
if prev.rules[rid] != nxt.rules[rid] # deep dict compare
]
new_checkpoints = [
rid for rid in changed + sorted(next_ids - prev_ids)
if nxt.rules[rid].get("requires_inspection")
and not prev.rules.get(rid, {}).get("requires_inspection")
]
return CodeDiff(
added=sorted(next_ids - prev_ids),
removed=sorted(prev_ids - next_ids),
changed=sorted(changed),
new_checkpoints=sorted(new_checkpoints),
)
A reviewer signs off on that CodeDiff, the edition is written to the registry as DRAFT, and only an explicit publish step flips it to ACTIVE on its effective date. The diff also feeds release notes for clerks and a migration checklist for the routing engine, so no downstream consumer is surprised by a checkpoint that appeared overnight.
Permalink to this section Resolve the Governing Edition Deterministically
Resolution is the function the rest of the platform calls millions of times: given a submission date and a code family, return the one edition that governed that file. It must be deterministic — the same inputs always yield the same edition — because a non-deterministic resolver would let an approved plan flip compliance state between two reads.
from datetime import date
class NoGoverningVersion(Exception):
"""No edition's effective window covers the requested date/family."""
class TaxonomyRegistry:
def __init__(self, versions: list[TaxonomyVersion]) -> None:
# Snapshot once; the registry is read-only after construction.
self._versions = tuple(versions)
def resolve(self, *, submitted_on: date, code_family: str) -> TaxonomyVersion:
"""Return the exact edition in force for a filing of `code_family`
submitted on `submitted_on`. Pure function of (date, family, registry)."""
candidates = [
v for v in self._versions
if v.code_family == code_family
and v.status in (Status.ACTIVE, Status.LEGACY_ACTIVE)
and v.effective_date <= submitted_on
and (v.deprecation_date is None or submitted_on < v.deprecation_date)
]
if not candidates:
raise NoGoverningVersion(
f"no edition of {code_family} governs {submitted_on.isoformat()}"
)
# Latest effective date wins; routing_priority breaks an overlap tie.
return max(candidates, key=lambda v: (v.effective_date, v.routing_priority))
Binding to the submission date is what prevents retroactive compliance shifts: an application filed under 2024-IRC keeps resolving to 2024-IRC long after 2025-IRC goes active, because 2024-IRC is now LEGACY_ACTIVE with a deprecation_date set past the last grandfathered file. The routing_priority tie-breaker handles the messy real case where a local amendment and a state edition share an effective date — the higher priority (typically the more specific local rule) wins, mirroring the precedence logic in the cross-referencing layer.
Because resolve is a pure function of immutable inputs, it is trivially cacheable. Wrap it at the API gateway so peak filing seasons do not translate into a query storm against the registry store.
from functools import lru_cache
@lru_cache(maxsize=4096)
def resolve_cached(submitted_on: date, code_family: str) -> str:
"""Cache by value: dates and strings are hashable and the registry is
immutable, so a cache entry can never go stale within a process. Returns
the calendar_tag; callers re-hydrate the full version by tag when needed."""
return REGISTRY.resolve(submitted_on=submitted_on, code_family=code_family).calendar_tag
Permalink to this section Wire Resolution Into Downstream Routing
A resolved edition is not the end — it is the key that unlocks the rest of the workflow. The version dictates the inspection checklist, the fee schedule, and the reviewer-assignment matrix for the file. For zoning-heavy jurisdictions it also selects which spatial rule set applies, so resolution must run before any geospatial check; pair it with mapping municipal zoning overlays to GIS data so a parcel is tested against the overlay edition that matches its permit, not whatever overlay happens to be current.
def build_review_plan(reg: TaxonomyRegistry, permit: dict) -> dict:
"""Attach the governing edition and its derived checklist to a file so
every downstream step reads one consistent, version-pinned plan."""
version = reg.resolve(
submitted_on=date.fromisoformat(permit["submitted_on"]),
code_family=permit["code_family"],
)
checkpoints = [
rid for rid, rule in version.rules.items()
if rule.get("requires_inspection")
]
return {
"application_no": permit["application_no"],
"governing_edition": version.calendar_tag, # shown on clerk dashboards
"schema_version": version.schema_version,
"inspection_checkpoints": checkpoints,
"fee_table": version.rules.get("__fees__", {}),
}
Surfacing governing_edition on version-aware dashboards is what eliminates clerk confusion during multi-year project reviews — the screen states plainly which code edition governs each active file. The cached validation contract that degraded-mode intake relies on is also a projection of the registry, which is why the resilience tier described in building fallback routing for legacy system downtime refuses new intake when its taxonomy cache ages past its TTL.
Permalink to this section Configuration Reference
Externalize every tunable so a fast-moving zoning code and a stable fire code can follow different lifecycle policies without code changes.
| Parameter | Type | Default | Municipal-context notes |
|---|---|---|---|
registry_path |
str |
/var/lib/permits/taxonomy.db |
Append-only SQLite file (WAL mode) on persistent disk. Published rows must be REVOKE UPDATE / read-only so a snapshot can never mutate. |
grandfather_window_days |
int |
730 |
Days a LEGACY_ACTIVE edition keeps governing in-flight files before it can be deprecated. Set to the longest statutory plan-validity period (often ~2 years). |
schema_cache_ttl_h |
int |
24 |
Max age of the cached validation contract before ingestion refuses new editions. Tighten when ordinances change often. |
resolver_cache_size |
int |
4096 |
lru_cache entries for resolve_cached. Size to the count of distinct (date, family) pairs seen in a filing season. |
default_routing_priority |
int |
0 |
Tie-breaker baseline; local amendments are published at a higher value so they win over a same-date state edition. |
publish_requires_review |
bool |
True |
Block any DRAFT → ACTIVE transition until a reviewer has signed off on the generated CodeDiff. |
retention_days |
int |
2555 |
How long deprecated editions are retained (~7 years) to satisfy public-records law; never auto-purge below the local mandate. |
Permalink to this section Error Handling and Edge Cases
Annual code cycles fail in specific, recurring ways that a naive overwrite mishandles:
- The resolution gap. A file’s submission date can fall before the earliest edition in the registry — common when migrating historical permits.
resolveraisesNoGoverningVersionrather than guessing; route the file to manual review with the gap flagged, never silently bind it to the oldest available edition. - Overlapping effective windows. A state edition and a local amendment can claim the same date. Relying on
max(effective_date)alone is non-deterministic across editions; therouting_prioritytie-breaker makes the winner explicit and testable, and any unresolved overlap should be caught in CI, not in production. - Mid-review code change. An edition flips
ACTIVEwhile a long plan review is open. Because resolution binds to the original submission date and the prior edition becomesLEGACY_ACTIVE, the in-flight file is unaffected — but only if the new release sets the old edition’sdeprecation_datepast the grandfather window instead of leaving it null-then-overwritten. - Stale validation cache. If the cached schema is older than
schema_cache_ttl_h, ingestion must refuse to publish a new edition it cannot validate against current field definitions, surfacing a clear operator message rather than admitting an unvalidated rule set. - Unauthorized publish. Flipping an edition to
ACTIVEis a privileged action; gate it behind the same policy as implementing role-based access for clerk portals so a code change cannot reach production routing without the right role and a recorded approval.
import structlog
log = structlog.get_logger()
def publish(reg_store, version: TaxonomyVersion, *, approved_by: str | None) -> None:
"""Promote a DRAFT edition to ACTIVE only with a recorded approval, and
only after confirming no same-date overlap is left ambiguous."""
if version.status is not Status.DRAFT:
raise ValueError("only DRAFT editions may be published")
if approved_by is None:
raise PermissionError("publish requires an authorized approver")
clash = reg_store.same_date_same_family(version) # returns conflicting rows
if clash and all(c.routing_priority == version.routing_priority for c in clash):
raise ValueError("ambiguous effective date: assign a distinct routing_priority")
reg_store.append(version) # append-only; never UPDATE
log.info("taxonomy.published", tag=version.calendar_tag, approved_by=approved_by)
Permalink to this section Testing and Verification
Versioning logic that is not tested against historical and future dates is just hope. Build a fixture registry with overlapping editions and assert the resolved tag for known dates — this is also your regression net before any go-live.
import pytest
from datetime import date
@pytest.fixture
def registry() -> TaxonomyRegistry:
return TaxonomyRegistry([
irc_2024(effective=date(2024, 1, 1), deprecation=date(2026, 1, 1),
status=Status.LEGACY_ACTIVE),
irc_2025(effective=date(2025, 1, 1), deprecation=None,
status=Status.ACTIVE),
])
def test_filing_binds_to_edition_in_force_on_submission(registry) -> None:
# Filed in 2024 -> must keep resolving to 2024-IRC even though 2025 is active.
v = registry.resolve(submitted_on=date(2024, 11, 3), code_family="IRC")
assert v.calendar_tag == "2024-IRC"
def test_new_filing_uses_current_edition(registry) -> None:
v = registry.resolve(submitted_on=date(2025, 6, 27), code_family="IRC")
assert v.calendar_tag == "2025-IRC"
def test_pre_registry_date_raises_not_guesses(registry) -> None:
with pytest.raises(NoGoverningVersion):
registry.resolve(submitted_on=date(2019, 1, 1), code_family="IRC")
Beyond unit tests, run a cutover drill before each annual go-live: replay a sample of historical, current, and post-effective-date submissions through resolve and assert every file maps to the edition its filing date implies. A passing drill is your evidence, during a state audit, that no application was retroactively re-bound when the new code went live.
Permalink to this section Integration Notes
Taxonomy versioning is the source of truth the rest of the architecture reads from rather than re-deriving. The normalized rule objects in every edition conform to the JSON schema for building permits, so a resolved version is immediately usable by the validation middleware without translation. The state-versus-local precedence baked into routing_priority is the runtime expression of the mappings maintained in cross-referencing state and local building codes — and where those mappings are generated automatically, see automating cross-walk tables between IBC and local amendments.
On the resilience side, the registry is the contract that degraded-mode routing caches, and a failed ingestion of a new edition is exactly the kind of event handled by error handling and retry logic for ingestion pipelines — a transient parse failure on an annual release should retry and quarantine, never publish a partial edition. Schedule quarterly registry audits to confirm that LEGACY_ACTIVE editions still route their grandfathered files correctly and that fully drained editions have moved to DEPRECATED.
Permalink to this section Frequently Asked Questions
Permalink to this section Why keep two identifiers — a calendar tag and a SemVer — instead of one?
They answer different questions for different audiences. The calendar tag (2025-IRC) is the public compliance boundary an applicant or plan reviewer reasons about; the SemVer (4.2.0) describes the internal shape of the normalized rule set so migration tooling knows whether a change is a patch, an additive minor, or a breaking major. Collapsing them forces either the public to see meaningless build numbers or your migration logic to parse a human label.
Permalink to this section How do I stop an annual update from retroactively invalidating approved plans?
Bind resolution to the submission date and never overwrite an edition. When the new code goes active, append it and move the prior edition to LEGACY_ACTIVE with a deprecation_date past your grandfather window. A file filed under the old edition keeps resolving to it because its submission date still falls inside that edition’s effective window — the new rules simply do not apply to it.
Permalink to this section What should happen when two editions share an effective date?
Make the winner explicit with routing_priority rather than relying on insertion order or a latest-wins accident. Local amendments are typically published at a higher priority so they take precedence over a same-date state edition, matching real statutory precedence. Treat any remaining ambiguity as a CI failure so it is caught before publish, not discovered during a contested review.
Permalink to this section Where should the version registry live so snapshots stay immutable?
In an append-only durable store, never an in-place table. A single-file SQLite database in WAL mode is the pragmatic default on a constrained municipal host; a Postgres table with UPDATE revoked works where that infrastructure exists. The one hard requirement is that a published edition’s row can never be mutated, so the historical record you present in an audit is exactly the one that governed the file.
Permalink to this section Related
- Core Architecture & Code Taxonomy for Municipal Permits — the parent architecture this versioning layer anchors.
- Designing JSON schemas for building permits — the contract every ingested edition normalizes into.
- Cross-referencing state and local building codes — the precedence rules
routing_priorityenforces at runtime. - Building fallback routing for legacy system downtime — degraded-mode intake that caches this registry as its validation contract.
- Error handling and retry logic for ingestion pipelines — how a failed annual-release ingestion retries and quarantines instead of publishing partial editions.