Syncing Inspector Calendars with Permit Milestones

This guide belongs to the Inspection Scheduling & Field Operations track, and shows how a permit’s lifecycle milestones become live, conflict-free events on the calendars inspectors already carry.

Permalink to this section Problem Statement and Scope

A building permit is a sequence of gated milestones: a footing inspection before concrete is poured, a framing inspection before insulation hides the studs, a rough electrical and mechanical pass, and a final inspection before a certificate of occupancy issues. Each milestone is a real appointment that must land on a real person’s calendar, at a real address, within a statutory window. When that scheduling is done by hand — a clerk phoning inspectors, a whiteboard in the field office, a shared spreadsheet nobody trusts — three failures recur.

The first is double-booking: two permits schedule the same inspector for the same 9:00 slot across town from each other, and one contractor’s crew stands idle at time-and-a-half. The second is stale calendars: an inspector reschedules on their phone, the change never propagates back to the permit system, and the office dispatches a second inspector to a job that was already moved. The third is capacity blindness: a district gets eleven final inspections queued for one Tuesday when the inspector can physically complete seven, so four contractors get a same-day cancellation and a bad review of the jurisdiction.

The people who feel these are concrete. Municipal clerks field the angry calls when a slot silently vanishes. Field inspectors lose an hour of daylight to a routing mistake. Python automation builders have to make the milestone-to-calendar mapping deterministic across CalDAV, Google Calendar, and Outlook without a vendor lock-in. And compliance officers need a defensible record that a required inspection was offered, scheduled, and either performed or documented as missed within the code-mandated interval.

The scope of this guide is the synchronization layer that sits between the permit lifecycle and the inspector’s calendar. Its inputs are a permit record with an issued status, its milestone definitions, and a roster of inspectors with working hours and daily capacity. Its outputs are calendar events written to each inspector’s provider (via the iCalendar format over CalDAV, or the Google and Outlook APIs), a two-way reconciliation loop that folds external edits back into the permit system, and an audit trail of every create, move, and cancel. Assigning which inspector gets a job by location is handled in the companion guide on routing inspectors by geographic proximity with Python; getting the times correct across offsets and daylight-saving transitions is covered in handling timezone and DST edge cases in inspection scheduling.

Two-way synchronization loop between permit milestones and inspector calendars A permit lifecycle milestone is converted to a scheduling intent. A capacity-and-conflict gate checks the inspector's daily load and existing bookings and either admits or rejects the intent. An admitted intent is serialized into an iCalendar VEVENT with a stable UID and pushed to the calendar provider over CalDAV or the Google and Outlook APIs. A reverse path polls the provider, matches returned events by UID and ETag, and reconciles external moves and cancellations back into the permit record, updating the milestone status. Each stage writes an append-only audit event capturing the actor, the milestone, and the outcome. intent admitted VEVENT reconcile edits · match by UID + ETag Permit milestone footing · framing final Capacity + conflict gate daily load · overlap admit / reject iCalendar serializer VEVENT · stable UID DTSTART / TZID Calendar provider CalDAV · Google Outlook Append-only audit log · every create, move, cancel actor · milestone · permit id · UID · outcome

Permalink to this section Prerequisites

This implementation targets Python 3.10+ and treats the permit datastore as an existing service that already emits milestone events. The calendar layer is provider-agnostic: it renders to the iCalendar (.ics) wire format, then delivers over CalDAV for self-hosted or Nextcloud/iCloud calendars, or through the vendor SDKs for Google and Microsoft 365.

pip install "icalendar>=5.0" "caldav>=1.3" "recurring-ical-events>=2.2" \
            "google-api-python-client>=2.130" "msgraph-sdk>=1.4" \
            "pydantic>=2.6" "tenacity>=8.3" "structlog>=24.1"

Environment assumptions:

  • Read access to issued permit records and their milestone definitions. The record shape should match your canonical JSON schema for building permits so a permit_id and parcel_id mean the same thing here as everywhere else in the platform.
  • A per-inspector calendar target: a CalDAV principal URL and app password, or an OAuth service account with delegated calendar scope for Google/Outlook. Store these as secrets, never in the repo.
  • A canonical jurisdiction time zone (an IANA key like America/Chicago), because every event you write must be anchored to a real offset — never a naive local time.
  • A durable table or event bus to persist the mapping between a milestone and the calendar event UID it produced, so reconciliation has a join key.

Permalink to this section Step 1 — Model Milestones as Schedulable Intents

Do not schedule directly from a permit status change. Translate each milestone into an explicit, typed scheduling intent first — a value object that carries everything the calendar layer needs and nothing it does not. This decouples the permit lifecycle from any one calendar vendor and gives you a single object to test, audit, and route.

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum


class Milestone(str, Enum):
    FOOTING = "footing"
    FRAMING = "framing"
    ROUGH_ELECTRICAL = "rough_electrical"
    ROUGH_MECHANICAL = "rough_mechanical"
    FINAL = "final"


# Typical on-site duration by milestone; drives the calendar block length.
DEFAULT_DURATION: dict[Milestone, timedelta] = {
    Milestone.FOOTING: timedelta(minutes=30),
    Milestone.FRAMING: timedelta(minutes=45),
    Milestone.ROUGH_ELECTRICAL: timedelta(minutes=30),
    Milestone.ROUGH_MECHANICAL: timedelta(minutes=30),
    Milestone.FINAL: timedelta(minutes=60),
}


@dataclass(frozen=True, slots=True)
class SchedulingIntent:
    permit_id: str
    parcel_id: str
    milestone: Milestone
    requested_start: datetime      # tz-aware; see the DST guide for why
    address: str
    lat: float
    lon: float

    @property
    def duration(self) -> timedelta:
        return DEFAULT_DURATION[self.milestone]

    @property
    def requested_end(self) -> datetime:
        return self.requested_start + self.duration

Because the intent carries the parcel’s coordinates, it is also the natural input to inspector assignment. The frozen=True dataclass makes an intent hashable and immutable, so it can be safely used as an audit-log payload without a downstream stage mutating it.

Permalink to this section Step 2 — Enforce Capacity and Reject Conflicts Before Writing

The gate is the heart of the system: never push an event to a provider until you have confirmed the inspector has both a free slot and remaining daily capacity. Doing this check against your own scheduling table — not against the remote calendar — keeps the decision fast and authoritative, and avoids a race where two intents both read “free” from a laggy provider.

from datetime import date


class CapacityError(Exception):
    """Raised when an inspector's day is full or the slot overlaps a booking."""


@dataclass(frozen=True, slots=True)
class Booking:
    inspector_id: str
    start: datetime
    end: datetime


def overlaps(a_start: datetime, a_end: datetime,
             b_start: datetime, b_end: datetime) -> bool:
    # Half-open intervals: touching edges (back-to-back) do NOT overlap.
    return a_start < b_end and b_start < a_end


def admit(intent: SchedulingIntent, inspector_id: str,
          day_bookings: list[Booking], daily_cap: int) -> Booking:
    """Return a Booking if the intent fits, else raise CapacityError."""
    same_day = [b for b in day_bookings
                if b.start.date() == intent.requested_start.date()]
    if len(same_day) >= daily_cap:
        raise CapacityError(
            f"{inspector_id} at capacity ({daily_cap}) on "
            f"{intent.requested_start.date().isoformat()}")
    for b in same_day:
        if overlaps(intent.requested_start, intent.requested_end, b.start, b.end):
            raise CapacityError(
                f"{inspector_id} double-booked at {intent.requested_start:%H:%M}")
    return Booking(inspector_id, intent.requested_start, intent.requested_end)

Two subtleties earn their place here. First, using half-open intervals means a 9:00–9:30 footing and a 9:30–10:15 framing are back-to-back, not a conflict — the common off-by-one that makes a full day look overbooked. Second, the daily cap is per inspector per calendar day, evaluated in the inspector’s own local date, which is exactly where daylight-saving edges bite; the DST edge-case guide shows why intent.requested_start.date() must be computed against an aware datetime.

Permalink to this section Step 3 — Serialize a Milestone to an iCalendar VEVENT

Render to the iCalendar format rather than to a provider’s proprietary JSON. Every major provider ingests .ics, so one serializer feeds CalDAV, Google, and Outlook. The two fields that matter most for correctness are a stable UID — deterministic from the permit and milestone so the same inspection always maps to the same event — and a DTSTART bound to a TZID, never a floating local time.

from icalendar import Calendar, Event
from zoneinfo import ZoneInfo


def build_uid(intent: SchedulingIntent) -> str:
    # Deterministic: re-serializing the same milestone yields the same UID,
    # so an update overwrites in place instead of creating a duplicate.
    return f"{intent.permit_id}.{intent.milestone.value}@municipalpermit.org"


def to_ics(intent: SchedulingIntent, inspector_id: str, tz: str) -> bytes:
    zone = ZoneInfo(tz)
    cal = Calendar()
    cal.add("prodid", "-//municipalpermit.org//inspection-scheduler//EN")
    cal.add("version", "2.0")
    cal.add("method", "REQUEST")     # invite semantics for two-way providers

    ev = Event()
    ev.add("uid", build_uid(intent))
    ev.add("summary", f"{intent.milestone.value.title()} inspection · {intent.permit_id}")
    ev.add("dtstart", intent.requested_start.astimezone(zone))
    ev.add("dtend", intent.requested_end.astimezone(zone))
    ev.add("location", intent.address)
    ev.add("geo", (intent.lat, intent.lon))
    ev.add("categories", ["inspection", intent.milestone.value])
    ev.add("x-permit-id", intent.permit_id)          # round-trips through providers
    ev.add("x-inspector-id", inspector_id)
    ev.add("dtstamp", datetime.now(tz=zone))
    ev.add("sequence", 0)            # bump on every update so clients accept the change
    cal.add_component(ev)
    return cal.to_ical()

The SEQUENCE property is easy to forget and expensive to omit: providers treat a re-sent invite with the same or lower sequence number as stale and silently drop your update, so every edit must increment it. The X-PERMIT-ID extension property survives the round trip through every provider, which is what lets Step 5 match a returned event back to its permit without a side table lookup.

Permalink to this section Step 4 — Deliver Over CalDAV (and the Vendor APIs)

CalDAV is the lowest-common-denominator transport and the right default for self-hosted municipal calendars. The pattern is: resolve the inspector’s calendar, then PUT the event at a URL derived from its UID so the operation is idempotent. Wrap the network call in a bounded retry — providers rate-limit and occasionally return transient 5xx during their own maintenance windows.

import caldav
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type


class ProviderTransientError(Exception):
    pass


@retry(stop=stop_after_attempt(4),
       wait=wait_exponential(multiplier=0.5, max=8),
       retry=retry_if_exception_type(ProviderTransientError))
def push_caldav(calendar_url: str, username: str, password: str,
                ics: bytes, uid: str) -> str:
    """Idempotently PUT the event; return the server ETag for reconciliation."""
    client = caldav.DAVClient(url=calendar_url, username=username, password=password)
    calendar = client.calendar(url=calendar_url)
    try:
        # save_event with an explicit UID overwrites the same resource in place.
        event = calendar.save_event(ics.decode("utf-8"))
    except caldav.lib.error.DAVError as exc:  # narrow to transient in production
        raise ProviderTransientError(str(exc)) from exc
    # The ETag changes whenever the event is edited anywhere — our sync token.
    return event.get_property(caldav.elements.dav.GetEtag()) or ""

For Google and Outlook the transport differs but the contract is identical: send the same VEVENT (both SDKs accept iCalendar import), persist the returned resource identifier and change token, and treat the UID as the primary key. Keep the delivery adapter behind a small interface (push(ics, target) -> sync_token) so a jurisdiction that switches providers changes one class, not the pipeline. When an inspection is scheduled or moved, notifying the contractor is a separate concern handled by the automated inspection notification system, which subscribes to the same audit events this layer emits.

Permalink to this section Step 5 — Reconcile External Edits Back Into the Permit System

Two-way sync is what separates a real scheduler from a fire-and-forget exporter. Inspectors will drag events in their phone app; when they do, the permit record must learn about it. Poll each calendar for changes since your last sync token, match returned events to milestones by UID, and fold moves and cancellations back into the permit state — then re-run the capacity gate so a self-service move cannot create a hidden double-booking.

import structlog

log = structlog.get_logger("inspection.sync")


@dataclass(frozen=True, slots=True)
class RemoteChange:
    uid: str
    new_start: datetime | None     # None => the inspector cancelled
    etag: str


def reconcile(changes: list[RemoteChange],
              known_etags: dict[str, str]) -> list[dict]:
    """Fold provider-side edits back into permit milestones."""
    applied: list[dict] = []
    for change in changes:
        if known_etags.get(change.uid) == change.etag:
            continue                      # we already have this version; skip
        if change.new_start is None:
            outcome = {"uid": change.uid, "action": "cancelled"}
        else:
            outcome = {"uid": change.uid, "action": "moved",
                       "new_start": change.new_start.isoformat()}
        log.info("reconciled", **outcome, etag=change.etag)
        known_etags[change.uid] = change.etag
        applied.append(outcome)
    return applied

The ETag comparison makes reconciliation idempotent: replaying the same change set is a no-op, which matters because polling windows overlap and providers occasionally re-serve an unchanged event. A cancellation coming back from the provider should not silently drop the milestone — mark it as “inspector-declined” and re-queue it for assignment, because the statutory obligation to offer the inspection still stands.

Permalink to this section Configuration Reference

Parameter Type Default Municipal-context notes
jurisdiction_tz str "America/Chicago" IANA key; the single source of truth for local dates and DST.
daily_capacity int 8 Max inspections per inspector per local day; tune per district and travel load.
slot_granularity_min int 15 Rounds requested starts to a grid so back-to-back blocks align cleanly.
sync_poll_seconds int 300 Reconciliation cadence; under 5 min risks provider rate limits.
provider str "caldav" caldav, google, or outlook; selected per inspector, not globally.
ics_method str "REQUEST" REQUEST gives invite/RSVP semantics; PUBLISH for read-only feeds.
retry_max_attempts int 4 Bounded exponential backoff on transient provider errors.
overlap_mode str "half_open" Half-open intervals let back-to-back slots touch without a false conflict.
cancel_policy str "requeue" On a provider-side cancel, re-queue rather than drop the statutory obligation.

Permalink to this section Error Handling and Edge Cases

Municipal calendar sync breaks in specific, recurring ways. Handle each explicitly rather than letting it surface as a stuck queue or a silent no-show.

  • Duplicate events from a non-deterministic UID. If the UID is randomly generated, every re-sync creates a new event and the inspector’s day fills with ghosts. Derive the UID from permit_id + milestone (Step 3) so an update overwrites in place.
  • Lost update from a stale SEQUENCE. A provider drops your edit because the re-sent invite carried the same sequence number. Increment SEQUENCE on every mutation and store the last value alongside the UID.
  • Race between two intents for one slot. Two milestones evaluate the gate concurrently and both see the slot free. Serialize per-inspector writes with a row lock or a per-inspector queue so the capacity check and the booking insert are atomic.
  • DST-shifted appointment. A 2:30 AM booking on a spring-forward night, or an ambiguous 1:30 AM in fall, drifts by an hour. This is common enough to warrant its own treatment — see handling timezone and DST edge cases.
  • Provider outage during a maintenance window. A CalDAV endpoint returns 503 for ten minutes. The bounded retry absorbs short blips; for longer outages, queue the intent and let a background worker drain it, so a downstream provider failure never blocks the permit lifecycle. The records this layer consumes are guarded upstream by role-based access for clerk portals, so a queued push should still carry the authenticated actor for the audit trail.

Permalink to this section Testing and Verification

Scheduling logic is exactly the kind of code that must be tested as data: enumerate the conflict and capacity cases and assert the decision, so a refactor can never quietly reintroduce a double-booking.

import pytest
from zoneinfo import ZoneInfo

CT = ZoneInfo("America/Chicago")


def _intent(hour: int, minute: int = 0) -> SchedulingIntent:
    return SchedulingIntent(
        permit_id="P-2200", parcel_id="0042", milestone=Milestone.FOOTING,
        requested_start=datetime(2026, 7, 15, hour, minute, tzinfo=CT),
        address="10 Main St", lat=41.87, lon=-87.62)


def test_back_to_back_is_not_a_conflict():
    nine = Booking("insp-1", datetime(2026, 7, 15, 9, 0, tzinfo=CT),
                   datetime(2026, 7, 15, 9, 30, tzinfo=CT))
    # 9:30 start touches the 9:30 end — half-open, so it must be admitted.
    booking = admit(_intent(9, 30), "insp-1", [nine], daily_cap=8)
    assert booking.start.hour == 9


def test_overlap_is_rejected():
    nine = Booking("insp-1", datetime(2026, 7, 15, 9, 0, tzinfo=CT),
                   datetime(2026, 7, 15, 9, 30, tzinfo=CT))
    with pytest.raises(CapacityError):
        admit(_intent(9, 15), "insp-1", [nine], daily_cap=8)


def test_daily_cap_is_enforced():
    full = [Booking("insp-1", datetime(2026, 7, 15, 8 + i, 0, tzinfo=CT),
                    datetime(2026, 7, 15, 8 + i, 30, tzinfo=CT)) for i in range(8)]
    with pytest.raises(CapacityError):
        admit(_intent(17), "insp-1", full, daily_cap=8)


def test_uid_is_deterministic():
    assert build_uid(_intent(9)) == build_uid(_intent(9))

A passing conflict matrix plus a deterministic-UID assertion gives you a regression net: change the interval math or the UID scheme and the suite tells you exactly which invariant moved. Add one integration test that serializes an intent, parses it back with icalendar, and asserts the DTSTART carries a TZID — the single most common correctness bug in calendar code.

Permalink to this section Integration Notes

This synchronization layer sits between two other subsystems in the Inspection Scheduling & Field Operations track. Upstream of the capacity gate, deciding which inspector should take a job by location is the job of routing inspectors by geographic proximity with Python; that router hands this layer an inspector_id and the gate confirms the slot is free. Downstream, every create/move/cancel event this layer emits is consumed by the automated inspection notification system so contractors learn of a scheduled or moved appointment without a clerk placing a call.

The permit records that seed a milestone come from the platform’s core architecture: their shape is fixed by the JSON schema for building permits, and read access to them is scoped by role-based access for clerk portals, so the same identity that mutates a milestone appears in this layer’s audit trail. Keeping the milestone-to-UID mapping in a durable store, rather than reconstructing it from the provider, is what makes the whole loop replayable after an outage.

Permalink to this section Frequently Asked Questions

Permalink to this section Should I write to the inspector’s calendar directly or export an ICS feed they subscribe to?

Write directly when you need two-way sync and capacity enforcement — a subscribed read-only feed cannot tell you when an inspector moved an event, so double-bookings created on the phone stay invisible. Use a published ICS feed only for downstream, read-only audiences such as contractors, where the appointment is informational and the source of truth stays in the permit system.

Permalink to this section How do I stop the same milestone from creating duplicate calendar events?

Derive a deterministic UID from the permit ID and milestone, and PUT/save at a URL keyed on that UID so a re-sync overwrites the existing event in place. Pair that with an incrementing SEQUENCE so providers accept the update rather than treating it as a stale replay. Random UIDs are the number-one cause of ghost events.

Permalink to this section What is the right way to handle capacity across a district with several inspectors?

Evaluate capacity per inspector per local calendar day, and make the assignment decision — which inspector — a separate, earlier step driven by geography. That separation lets you rebalance load by reassigning intents before they reach the gate, rather than discovering an overloaded day only when the gate starts rejecting.

Permalink to this section Do I need CalDAV if all my inspectors use Google or Outlook?

No. Serialize to the iCalendar format once and deliver through the Google or Microsoft Graph SDK, which both import VEVENTs and return a change token you can use for reconciliation. CalDAV earns its place only when you run self-hosted or mixed-provider calendars and want one transport for all of them.