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.

resolve_constraint walks the ordered authorities top-down and returns the first match A parcel carrying parcel_id APN-4417 calls resolve_constraint with key "setback_ft". The resolver walks the declared authority order top-down. The primary_jurisdiction overlay, state_energy_compliance, holds {energy_rating, solar_ready} and does not constrain setback_ft, so the walk continues. The first secondary jurisdiction, county_zoning_overlay, holds {setback_ft: 25, far: 0.6}; it is the first match and becomes the binding rule, returning setback_ft = 25. The second secondary jurisdiction, muni_fire_safety, also holds setback_ft: 15 but is lower in the order, so it is overridden and never returned. Parcel parcel_id: APN-4417 resolve_constraint( key="setback_ft") walk order PRIMARY state_energy_compliance { energy_rating, solar_ready } key absent SECONDARY · 0 county_zoning_overlay { setback_ft: 25, far: 0.6 } first match SECONDARY · 1 muni_fire_safety { setback_ft: 15, occupancy: A2 } overridden BINDING RULE · return setback_ft = 25 from county_zoning_overlay
# 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.