How to Structure Permit Application JSON for Multi-Jurisdiction Use
This guide extends designing JSON schemas for building permits, the validation contract within the core architecture and code taxonomy for municipal permits, to the hardest case in that contract: a single parcel governed by overlapping state, county, and municipal authorities at once.
A development site does not respect organizational charts. One parcel can sit under a state building code, a county zoning overlay, and a municipal fire-safety ordinance simultaneously, and an application must satisfy all three before it can be issued. When a payload mixes invariant applicant data with conditional jurisdictional rules in a single flat object, two failures follow: validation drift, where the same field means different things to different authorities, and non-deterministic routing, where the system cannot decide which authority’s constraint wins. The fix is a payload shape that strictly separates a canonical base record from jurisdiction-scoped overlays, plus a deterministic precedence order over the authorities that apply.
Permalink to this section Why Multi-Jurisdiction Payloads Break
The compliance stakes are concrete. If a county mandates a 25-foot setback variance while a municipality requires a separate environmental review of the same lot, a payload that cannot express which rule takes precedence will either reject a valid application or — far worse — issue a permit under the wrong ruleset. That is the defect that surfaces months later as a stop-work order and an open-records dispute.
The inputs are messier than a single portal form suggests. A multi-jurisdiction payload arrives as JSON from an API integration, a clerk portal, or a parsed PDF application, and it carries fields from authorities that were never coordinated: county_zoning_overlay, muni_fire_safety, and state_energy_compliance blocks that each evolved their own key names and enumerations. Key collisions between these blocks — two authorities both defining review_status with incompatible meanings — are the single most common cause of silent corruption. The audience that pays for this is specific: municipal clerks triage the records automation could not route, Python automation builders litter the codebase with defensive branches, and compliance officers cannot prove every application met the right authority’s rules. A disciplined payload shape collapses all three problems into one well-typed artifact.
Permalink to this section Step 1 — Separate the Canonical Base From Jurisdictional Overlays
Model the invariant record once. Applicant identity, contractor licensing, parcel identifiers, and project scope do not change with filing location, so they belong in a base model that is the single source of truth. Everything jurisdiction-specific lives in a separate, namespaced overlay keyed by an authority code — never merged into the base.
# models.py — Python 3.10+, pydantic v2 for boundary-level typing.
from pydantic import BaseModel, Field
class CanonicalBase(BaseModel):
"""Invariant data — identical regardless of which authorities apply."""
application_id: str
parcel_id: str # APN; resolved against the county roll
applicant_name: str
contractor_license: str | None = None
project_scope: str # enum-constrained in the JSON Schema
valuation_usd: int = Field(ge=0)
class JurisdictionOverlay(BaseModel):
"""One authority's scoped requirements. Never flattened into the base."""
authority_code: str # e.g. "county_zoning_overlay"
ruleset_version: str # semver of that authority's schema
constraints: dict[str, object] # authority-specific keys live here only
class MultiJurisdictionPayload(BaseModel):
base: CanonicalBase
primary_jurisdiction: str # authority_code that wins ties
secondary_jurisdictions: list[str] = [] # ordered: index 0 outranks index 1
overlays: dict[str, JurisdictionOverlay] # keyed by authority_code
Nesting every authority’s keys inside its own overlays[authority_code].constraints block guarantees that muni_fire_safety.review_status can never collide with county_zoning_overlay.review_status. The base stays clean, and each overlay carries its own ruleset_version so you can validate it against the exact schema that authority published — the same versioning discipline used when versioning permit code taxonomies for annual updates.
Permalink to this section Step 2 — Namespace and Order the Authorities
Separation alone does not resolve conflicts; you also need an explicit precedence order. The payload declares one primary_jurisdiction and an ordered secondary_jurisdictions array, and the resolver walks them from highest authority to lowest, returning the first binding constraint it finds.
# precedence.py — deterministic conflict resolution across authorities.
def resolve_constraint(payload: MultiJurisdictionPayload, key: str) -> object | None:
"""Return the binding value for `key` using declared authority order.
Highest authority wins: primary first, then secondary in array order.
Returns None when no applicable authority constrains the key.
"""
order = [payload.primary_jurisdiction, *payload.secondary_jurisdictions]
for authority_code in order:
overlay = payload.overlays.get(authority_code)
if overlay and key in overlay.constraints:
return overlay.constraints[key] # first match is authoritative
return None
Because the order is data, not code, a clerk reclassifying a parcel from city to county jurisdiction is a one-line edit to primary_jurisdiction — no redeploy. This is also where overlapping building-code requirements get reconciled, the runtime counterpart to statically cross-referencing state and local building codes.
Permalink to this section Step 3 — Compile and Run Two-Pass Validation
Compiling a JSON Schema on every request is the throughput killer in high-volume intake. Build validators once at startup, cache them in memory, and validate in two passes: a cheap canonical pass that rejects structurally broken payloads before any expensive rule merging, then a per-authority pass keyed by the overlay’s ruleset_version.
# validate.py — pre-compiled Draft 2020-12 validators, two-pass strategy.
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError
# Built ONCE at process startup, not per request.
_BASE_VALIDATOR = Draft202012Validator(load_schema("schemas/base.json"))
_OVERLAY_VALIDATORS: dict[tuple[str, str], Draft202012Validator] = {
(code, ver): Draft202012Validator(load_schema(f"schemas/{code}/{ver}.json"))
for code, ver in published_authority_versions() # warmed at boot
}
def validate_payload(raw: dict) -> list[ValidationError]:
errors: list[ValidationError] = []
# Pass 1 — canonical: fail fast on core structural defects.
errors.extend(_BASE_VALIDATOR.iter_errors(raw["base"]))
if errors:
return errors # don't merge rules over bad core data
# Pass 2 — per-authority: validate each overlay against its pinned version.
for code, overlay in raw["overlays"].items():
key = (code, overlay["ruleset_version"])
validator = _OVERLAY_VALIDATORS.get(key)
if validator is None:
raise UnknownRulesetError(code, overlay["ruleset_version"])
errors.extend(validator.iter_errors(overlay["constraints"]))
return errors
Deferring overlay validation until the base passes keeps memory flat and prevents one authority’s mid-year code change from cascading into unrelated rejections.
Permalink to this section Step 4 — Resolve Jurisdiction From Parcel Geometry
Static schemas cannot answer “which authorities apply?” for annexation zones, unincorporated county islands, or a parcel straddling a municipal boundary. Decouple that question from validation entirely: carry the parcel_id (APN) or a bounding box in the base record, and resolve the applicable authorities at runtime against the parcel layer — the same geometry that drives mapping municipal zoning overlays to GIS data.
# geo.py — derive applicable authorities from parcel geometry, not static fields.
def resolve_authorities(parcel_id: str, gis) -> tuple[str, list[str]]:
"""Look up which authorities govern a parcel via the GIS service.
Returns (primary_jurisdiction, ordered secondary_jurisdictions).
Raising here is correct: an unresolvable parcel must not be auto-routed.
"""
feature = gis.lookup_parcel(parcel_id) # county assessor / PostGIS
if feature is None:
raise ParcelNotFoundError(parcel_id) # queue for manual review
overlays = feature["intersecting_authorities"] # ordered by GIS precedence
return overlays[0], overlays[1:]
Resolving authorities from geometry rather than a self-declared field eliminates the race condition where a municipal merger silently changes who governs a lot; the parcel layer is updated once and every new application routes correctly.
Permalink to this section Step 5 — Emit Structured, Pointer-Addressed Errors
A rejection a clerk cannot act on is as bad as a silent failure. Every error must name three things: the JSON Pointer to the offending property, the authority whose rule fired, and a machine-readable code. Standardizing this shape lets you build self-service correction portals and consistent fallback routing for legacy system downtime.
# errors.py — uniform, audit-ready error envelope.
def to_envelope(err: ValidationError, authority_code: str) -> dict[str, str]:
return {
"pointer": "/" + "/".join(str(p) for p in err.absolute_path), # RFC 6901
"authority": authority_code, # which ruleset rejected the value
"code": err.validator, # e.g. "enum", "required", "minimum"
"message": err.message,
}
Permalink to this section Parameter and Flag Reference
| Parameter | Type | Recommended | Rationale for permit payloads |
|---|---|---|---|
primary_jurisdiction |
str |
required | Single tie-breaking authority; never inferred silently |
secondary_jurisdictions |
list[str] |
ordered, may be empty | Array index is the precedence; document the ordering rule |
overlays[code].ruleset_version |
str (semver) |
pinned per authority | Validates against the exact published schema; survives annual updates |
| JSON Schema dialect | str |
draft/2020-12 |
Stable if/then and unevaluatedProperties semantics |
unevaluatedProperties |
bool |
false on overlays |
Rejects stray keys that signal namespace leakage |
| Validator caching | strategy | compile at startup | Removes per-request compile cost under peak filing load |
| Geometry key | APN or bbox |
APN preferred | Stable identifier; bbox only when no parcel ID exists |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Key collisions between overlays
Two authorities define the same key with different meanings. Fix: never merge overlays into one namespace; keep each under overlays[authority_code].constraints and set unevaluatedProperties: false so a leaked key fails loudly instead of being silently absorbed.
Permalink to this section Self-declared jurisdiction drift
A payload hard-codes primary_jurisdiction that no longer matches the parcel after an annexation. Fix: treat any self-declared authority as a hint only; re-derive authorities from resolve_authorities() at intake and reject when the declared and resolved sets disagree.
Permalink to this section Unpinned overlay versions
An overlay validates against “latest,” so a mid-year code revision retroactively invalidates accepted records. Fix: require ruleset_version on every overlay and key the validator cache on (authority_code, version); treat an unknown version as UnknownRulesetError, not a soft pass.
Permalink to this section GIS timeout treated as “no jurisdiction”
The parcel lookup times out and the resolver returns an empty authority list, so the payload skips all overlay checks. Fix: distinguish resolved-empty from unresolved; on timeout, raise and queue the application for manual review rather than routing it as unconstrained.
Permalink to this section Lost error paths after merging
Constraint sets get merged before validation, so a failure points at a synthetic path no clerk can locate in the original form. Fix: validate each overlay independently and emit RFC 6901 pointers scoped to that overlay, preserving the path the submitter actually sees.
Permalink to this section Audit and Logging Guidance
Compliance officers need to reconstruct why an application was accepted or rejected long after the fact, so log decisions, not just exceptions. For every submission record: the resolved primary_jurisdiction and secondary_jurisdictions, each overlay’s authority_code and ruleset_version, every binding constraint returned by resolve_constraint during routing, and the full structured-error envelope for rejections. Tag entries with machine-readable severity so audits never require manual log parsing, and retain them for the same window as the permit record itself to satisfy state open-records mandates. Logging the applied precedence — not merely the declared order — is what lets an officer prove that a contested permit was evaluated under the correct authority.
Permalink to this section Frequently Asked Questions
Permalink to this section Should jurisdiction rules live in the JSON payload or in server-side config?
Keep authority rules (enums, constraints, versions) in server-side schemas; the payload should carry only the parcel’s facts and the resolved authority order. Embedding rules in the payload makes every applicant a source of truth and guarantees drift.
Permalink to this section How do I version overlays when authorities update codes at different times?
Pin each overlay independently with its own ruleset_version and key the validator cache on (authority_code, version). Authorities revise on their own calendars, so a single global schema version cannot represent them — see versioning permit code taxonomies for annual updates.
Permalink to this section What happens when two authorities impose contradictory constraints?
The declared precedence order resolves it: resolve_constraint returns the highest-ranked authority’s value and logs the override. If two authorities at the same rank conflict, that is a configuration error and should be surfaced for human review, not auto-resolved.
Permalink to this section Can I validate a multi-jurisdiction payload with plain JSON Schema alone?
For structure and per-authority constraints, yes — Draft 2020-12 handles it. But geometry-driven authority resolution and cross-authority precedence are runtime concerns that schema validation cannot express; keep those in code, as Steps 2 and 4 show.
Permalink to this section Related
- Designing JSON Schemas for Building Permits — the parent contract this page extends to overlapping authorities.
- Versioning Permit Code Taxonomies for Annual Updates — how to pin overlay rulesets so accepted records stay valid.
- Mapping Municipal Zoning Overlays to GIS Data — the parcel geometry that drives runtime authority resolution.
- Cross-Referencing State and Local Building Codes — reconciling the code requirements that overlapping authorities impose.