Designing JSON Schemas for Building Permits

A building-permit JSON schema is the validation contract that sits at the front door of the core architecture and code taxonomy for municipal permits: every inbound application is measured against it before the workflow engine is allowed to touch the record. This guide builds that contract end to end — from modeling the permit code taxonomy as data, through conditional and spatial constraints, to compiling the schema and emitting structured errors a clerk can act on.

Permalink to this section What Breaks Without a Strict Schema, and Who It Affects

When permit intake has no enforced contract at the boundary, malformed submissions do not fail loudly — they fail later and quietly. A missing valuation slips through and a fee is calculated as $0. A free-text permit_type of “elec.” never matches the inspection-routing table, so the application stalls in an unassigned queue. A parcel ID with a transposed digit passes intake and is only caught weeks later by a clerk reconciling against the county roll. Each of these is a compliance defect that surfaces downstream as an exception, a backlog, or — worst case — a permit issued under the wrong ruleset.

The people who feel this are specific. Municipal clerks inherit the ambiguous records that automation could not route and must triage them by hand. Python automation builders spend their time writing defensive if-checks scattered across the codebase instead of trusting a single boundary. Compliance officers cannot prove, during an audit, that every application was evaluated against the correct fields and enumerations. A rigorously designed schema collapses all of that into one artifact: the input is an arbitrary JSON payload from a portal, an API integration, or a parsed PDF application; the output is either an accepted, fully-typed record or a structured rejection that names exactly which constraint failed and why.

The schema validation boundary for a building permit An untrusted inbound JSON payload from a portal, API, or parsed PDF enters a single schema-validation boundary. Inside, it passes through three stacked constraint layers in order: typing and enums, conditional if/then rules, and spatial GeoJSON geometry. The result reaches a decision: if there are no errors the payload is accepted as a typed PermitApplication record and handed to the workflow queue; otherwise it is rejected as a structured error list of field, rule, and message objects returned to the applicant. Inbound JSON payload from any source: portal POST API integration parsed PDF fields arbitrary, untrusted shape SCHEMA VALIDATION BOUNDARY compile once · validate per request 1 Typing & enums type · pattern · format enum (taxonomy) · additionalProperties:false 2 Conditional rules if / then · allOf · oneOf demolition → asbestos · ≥ $500k → review 3 Spatial geometry GeoJSON type · coordinate range well-formed before any GIS query errors empty? pass ACCEPT Typed PermitApplication → workflow queue fail REJECT Structured error list [{field, rule, message}] → back to applicant

Permalink to this section Prerequisites Checklist

This component targets Python 3.10+ (the union X | None syntax and StrEnum patterns below assume it). Install the two validation engines used throughout — one for declarative JSON Schema documents, one for typed Python models:

pip install "jsonschema>=4.21"   # Draft 2020-12 validation engine
pip install "pydantic>=2.6"      # typed models + edge validation at the API boundary

Environment and data assumptions before you start:

  • A canonical permit code taxonomy — the authoritative list of permit types, zoning districts, and construction typologies for your jurisdiction, ideally exported from the same source used for versioning permit code taxonomies for annual updates so the enums in your schema and your fee tables never drift apart.
  • Read access to the parcel roll — the county GIS or assessor dataset that lets you confirm a submitted parcel_id actually exists.
  • A pinned JSON Schema dialect. Every schema document below declares "$schema": "https://json-schema.org/draft/2020-12/schema" so that if/then/else and unevaluatedProperties behave consistently across machines.

Permalink to this section Stage 1 — Model the Taxonomy as Enumerated Constraints

The schema’s job begins with eliminating ambiguity, and free-text fields are the primary source of it. Anything that has a finite, governed set of valid values — permit type, zoning district, review status — must be expressed as an enum (or const for a single fixed value) rather than an open string. Source these enums from the taxonomy export, never hand-type them, so a new permit class added to the code book propagates into validation automatically.

# taxonomy.py — load the governed vocabularies once at import time.
import json
from pathlib import Path

_TAXONOMY: dict[str, list[str]] = json.loads(
    Path("taxonomy/active.json").read_text(encoding="utf-8")
)

PERMIT_TYPES: list[str] = _TAXONOMY["permit_types"]      # e.g. ["building", "electrical", ...]
ZONING_DISTRICTS: list[str] = _TAXONOMY["zoning_codes"]  # e.g. ["R-1", "R-2", "C-1", ...]

These lists feed directly into the schema document so the controlled vocabulary lives in exactly one place.

Permalink to this section Stage 2 — Define the Base Contract

The base schema captures the invariant core of every application regardless of permit class: who is applying, which parcel, what the project is worth. Strict typing, an explicit required array, and pattern constraints on identifiers turn the most common data-entry defects into boundary rejections.

# schema.py — the invariant base contract every payload must satisfy.
from taxonomy import PERMIT_TYPES, ZONING_DISTRICTS

BASE_SCHEMA: dict = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://municipalpermit.org/schema/building-permit/1.0",
    "type": "object",
    "required": ["parcel_id", "permit_type", "valuation", "applicant"],
    "additionalProperties": False,  # reject unknown keys instead of silently ignoring them
    "properties": {
        # County APN format — a transposed digit fails here, not at a clerk's desk.
        "parcel_id": {"type": "string", "pattern": r"^\d{2}-\d{4}-\d{3}$"},
        "permit_type": {"type": "string", "enum": PERMIT_TYPES},
        "zoning_district": {"type": "string", "enum": ZONING_DISTRICTS},
        "valuation": {"type": "number", "exclusiveMinimum": 0},
        "applicant": {
            "type": "object",
            "required": ["name", "email"],
            "properties": {
                "name": {"type": "string", "minLength": 1},
                "email": {"type": "string", "format": "email"},
                "contractor_license": {"type": "string", "pattern": r"^[A-Z]{1,3}\d{4,8}$"},
            },
        },
    },
}

Setting additionalProperties to False is deliberate: in a municipal context an unexpected field usually signals a mismatched portal version or an injection attempt, and silently dropping it hides the problem.

Permalink to this section Stage 3 — Encode Conditional and Jurisdictional Rules

Real ordinances are conditional: a demolition permit requires an asbestos-survey reference; any project over a valuation threshold triggers a structural plan review; a parcel in a historic overlay needs a preservation-board field. JSON Schema expresses these as if/then blocks and oneOf/anyOf branches, which keeps the rules declarative and auditable rather than buried in application code.

# Conditional requirements layered onto the base contract.
CONDITIONAL_RULES: dict = {
    "allOf": [
        {
            # Demolition work must reference an asbestos survey.
            "if":   {"properties": {"permit_type": {"const": "demolition"}}},
            "then": {"required": ["asbestos_survey_id"]},
        },
        {
            # High-value projects require a structural review flag.
            "if":   {"properties": {"valuation": {"minimum": 500_000}}},
            "then": {"required": ["structural_review"]},
        },
    ]
}

Because jurisdictions diverge — setbacks, fire-suppression mandates, and preservation overlays vary by locality — keep these conditional blocks as composable sub-schemas rather than hard-coding one municipality’s rules into the base. That separation of invariant core from jurisdictional overrides is the full subject of how to structure permit application JSON for multi-jurisdiction use, and it is what lets a single validation pipeline serve many counties. When a conditional rule encodes a statutory requirement, anchor it to a version-controlled reference so local ordinances and state mandates stay synchronized — the mechanics of that reconciliation are covered in cross-referencing state and local building codes.

Permalink to this section Stage 4 — Add Spatial Validation

Building permits are inherently spatial, so the contract should carry the geometry needed to validate a parcel against zoning overlays, floodplains, and historic districts. Embed a GeoJSON-shaped geometry object in the schema; the actual cross-reference against municipal layers happens against the patterns described in mapping municipal zoning overlays to GIS data, but the schema guarantees the geometry is well-formed before that check runs.

# A minimal GeoJSON Point/Polygon constraint embedded in the contract.
GEOMETRY_SCHEMA: dict = {
    "type": "object",
    "required": ["type", "coordinates"],
    "properties": {
        "type": {"enum": ["Point", "Polygon"]},
        # WGS84 longitude/latitude ranges — out-of-range coordinates fail here.
        "coordinates": {
            "type": "array",
            "items": {"type": "number"},
            "minItems": 2,
        },
    },
}

Validating geometry shape at the schema layer means the GIS service never receives a payload it cannot interpret, and an out-of-range coordinate is rejected as a structured error instead of throwing deep inside a spatial query.

Permalink to this section Stage 5 — Compile, Validate, and Route

With the layers defined, compile them once at startup into a single validator and reuse it per request. Recompiling on every call is a common and expensive mistake on constrained municipal servers; a pre-built validator turns validation into a hot-path operation.

# validate.py — compile once, validate many, return a routing decision.
from typing import Any
from jsonschema import Draft202012Validator
from schema import BASE_SCHEMA, CONDITIONAL_RULES, GEOMETRY_SCHEMA

# Merge the layers into one document and compile a reusable validator.
FULL_SCHEMA: dict = {
    **BASE_SCHEMA,
    **CONDITIONAL_RULES,
    "properties": {**BASE_SCHEMA["properties"], "geometry": GEOMETRY_SCHEMA},
}
_VALIDATOR = Draft202012Validator(FULL_SCHEMA)

def validate_application(payload: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
    """Return (accepted, errors). On failure, errors carry field + reason."""
    errors = sorted(_VALIDATOR.iter_errors(payload), key=lambda e: e.path)
    if not errors:
        return True, []
    structured = [
        {"field": "/".join(str(p) for p in e.absolute_path) or "<root>",
         "rule": e.validator,
         "message": e.message}
        for e in errors
    ]
    return False, structured

The boolean return is the routing signal: an accepted payload is handed to the workflow state machine; a rejected one returns its structured error list to the caller. That hard boundary — nothing enters the queue until it conforms — is the whole point of the contract.

Permalink to this section Configuration Reference

Parameter Type Default Municipal-context notes
$schema dialect string draft/2020-12 Pin it. if/then/unevaluatedProperties semantics differ across drafts; a drifting dialect silently changes validation behavior.
additionalProperties bool False Reject unknown keys. An unexpected field usually means a stale portal version or tampering, not benign extra data.
parcel_id pattern string (regex) ^\d{2}-\d{4}-\d{3}$ Match your county’s APN format exactly; this is the cheapest place to catch transposition errors.
valuation bound number exclusiveMinimum: 0 Blocks $0 filings that would compute a zero fee; high thresholds also drive plan-review triggers.
format: email string off by default The jsonschema engine does not enforce format unless a format checker is registered — wire one in or it is documentation only.
Validator lifetime object compile-once Build Draft202012Validator at startup, never per request, on memory-limited servers.
Schema $id version string …/1.0 Bump on breaking field changes so historical records pin to the contract that evaluated them.

Permalink to this section Error Handling and Edge Cases

Several failure modes are specific to municipal intake and worth handling explicitly rather than letting them surface as opaque 500s.

  • format is advisory by default. In jsonschema, keywords like format: "email" do not fail validation unless you attach a format checker. Pass format_checker=Draft202012Validator.FORMAT_CHECKER when constructing the validator if email/date formats must be enforced.
  • Numeric strings from legacy portals. Older intake forms submit "valuation": "350000" as a string. JSON Schema will reject it against type: number — correct, but unhelpful to the applicant. Coerce known-numeric fields in a pre-validation pass, or accept ["number", "string"] with a pattern and normalize after.
  • Encoding and Unicode in names. Applicant names arrive in mixed encodings; normalize to NFC before validation so a minLength check and later database lookups see the same bytes.
  • Conditional-rule gaps. An if/then with no matching else silently passes payloads the rule never anticipated. Pair high-stakes conditionals with an explicit else or a catch-all required so a new permit type cannot route around a mandate.
from jsonschema import Draft202012Validator
# Enforce formats and surface a clean message instead of a raw stack trace.
_VALIDATOR = Draft202012Validator(
    FULL_SCHEMA, format_checker=Draft202012Validator.FORMAT_CHECKER
)

For a permit denied by validation, attach a human-readable correction hint rather than exposing internal validator keywords — applicants should see “Parcel ID must look like 12-3456-789”, not pattern mismatch at /parcel_id.

Permalink to this section Testing and Verification

Treat the schema as code: it has regressions, and they are silent. Pin a set of fixture payloads — at least one valid, and one per constraint you care about — and assert both acceptance and the exact rejection reason. Testing only the happy path lets a loosened constraint pass unnoticed.

# test_schema.py — verify both acceptance and precise rejection.
from validate import validate_application

VALID = {
    "parcel_id": "12-3456-789", "permit_type": "building",
    "valuation": 350_000, "applicant": {"name": "Ada Lovelace", "email": "[email protected]"},
}

def test_valid_payload_accepted() -> None:
    accepted, errors = validate_application(VALID)
    assert accepted and errors == []

def test_demolition_requires_asbestos_survey() -> None:
    bad = {**VALID, "permit_type": "demolition"}      # missing asbestos_survey_id
    accepted, errors = validate_application(bad)
    assert not accepted
    assert any(e["rule"] == "required" for e in errors)

def test_zero_valuation_rejected() -> None:
    accepted, _ = validate_application({**VALID, "valuation": 0})
    assert not accepted

Run these in CI on every taxonomy export and every schema change. A green suite is your evidence that a 1.0 → 1.1 schema bump did not quietly start accepting payloads the previous contract rejected — exactly the guarantee an audit asks for.

Permalink to this section Integration Notes

This schema is the seam between ingestion and the rest of the platform. Upstream, the payloads it validates are produced by the automated permit ingestion and parsing workflows track — portal posts, API integrations, and OCR-extracted PDF fields all converge on this single contract, which is why the schema must tolerate the imperfect output those sources produce.

Two adjacent components consume the contract directly. When an upstream source floods the pipeline with failures, the cached copy of this schema is what lets degraded mode keep validating locally — the failover path that relies on it is described in building fallback routing for legacy system downtime, and the same caching discipline appears in the retry logic of error handling and retry logic for ingestion pipelines. Downstream, an accepted record carries the validated fields that drive who may act on it under implementing role-based access for clerk portals — the schema decides what a record is, and access control decides who may touch it.

Permalink to this section Frequently Asked Questions

Permalink to this section Should I use JSON Schema documents or Pydantic models?

Use both, at different layers. JSON Schema is the portable, language-agnostic contract you can publish, version, and hand to a third-party integrator. Pydantic is the ergonomic enforcement layer at your Python API boundary, where you also want coercion and rich error objects. Generate one from the other where you can, but treat the JSON Schema document as the source of truth that auditors and external systems consume.

Permalink to this section How do I keep the schema’s enums in sync with the permit code book?

Never hand-edit enums. Source them from the same taxonomy export that feeds your fee tables and routing logic, and load them at import time as shown in Stage 1. When the code book changes, the export changes, and your schema picks up the new values on the next deploy — the discipline for those releases is covered in versioning permit code taxonomies for annual updates.

Permalink to this section Why reject unknown fields with additionalProperties: false?

Because in a permitting context an unexpected key almost never means harmless extra data. It usually signals a portal sending a stale payload shape or a malformed integration, and occasionally tampering. Rejecting it turns a silent mismatch into a clear, actionable error at the boundary instead of a corrupted record downstream.

Permalink to this section Does validating geometry in the schema replace a GIS check?

No. The schema only guarantees the geometry is well-formed — correct type, in-range coordinates. Confirming the parcel actually falls within a given zoning overlay or floodplain is a spatial query against live GIS layers, handled in the zoning-overlay mapping workflow. The schema’s job is to ensure the GIS service never receives a payload it cannot parse.