Tracking Escalation Timelines for Unresolved Violations

This guide extends modeling code violation lifecycles in Python, part of the Violation Tracking & Compliance Enforcement track. Where the parent guide defines the states a case moves through, this page computes the clocks between them — the statutory cure and escalation deadlines that decide, to the day, when an unresolved violation must be re-inspected, escalated, and moved toward a hearing.

The task is narrow and unforgiving: given the date a notice of violation was served, compute the exact calendar date the property owner’s cure period ends, then fire an automatic escalation the moment that deadline lapses without a passing re-inspection. Get the arithmetic wrong and the failure is legal, not cosmetic. Count a Saturday as a business day and you deny the owner a day of their statutory window, voiding any downstream fine; forget a municipal holiday and you escalate a compliant owner prematurely; ignore a tolling order and you keep a clock running that a court has paused. The inputs are a served-notice timestamp, a jurisdiction’s ordinance-defined cure length, a business-day and holiday calendar, and any tolling events. The output is a precise, timezone-correct deadline, a scheduled escalation job, and an audit entry a hearing officer can reconstruct.

Business-day cure-period timeline with holiday skips, tolling, and automatic escalation A horizontal timeline. On day zero a notice of violation is served, starting the cure period. The window is counted in business days, so two weekend spans and one municipal holiday are skipped and do not consume the window. A tolling event pauses the clock for a span, shifting the final deadline later. At the computed cure deadline, if no passing re-inspection has been recorded, a scheduled escalation job fires and the case transitions to Escalated. Each milestone writes an audit entry. Notice served day 0 · clock starts weekend holiday weekend tolling pause nominal deadline Escalation fires deadline lapsed, no passing re-inspection tolling shifts deadline →

Permalink to this section Step 1 — Compute a Business-Day Cure Deadline with Holidays

Most cure periods are defined in business days — “the owner shall have ten (10) business days to correct the cited condition.” A naive served + timedelta(days=10) is wrong: it counts weekends and holidays that the ordinance excludes. Roll the date forward one working day at a time, skipping weekends and a jurisdiction-supplied holiday set. Anchor everything to the municipality’s local timezone so a notice served at 4 p.m. does not roll into the next day because of a UTC conversion.

from __future__ import annotations
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo


def add_business_days(start: date, n: int, holidays: frozenset[date]) -> date:
    """Return the date n business days after `start`, skipping weekends + holidays."""
    if n < 0:
        raise ValueError("cure length must be non-negative")
    current = start
    remaining = n
    while remaining > 0:
        current += timedelta(days=1)
        if current.weekday() >= 5:        # Saturday=5, Sunday=6
            continue
        if current in holidays:
            continue
        remaining -= 1
    return current


def cure_deadline(served_at: datetime, cure_days: int,
                  holidays: frozenset[date], tz: str) -> datetime:
    """End-of-business on the final cure day, in the municipality's own timezone."""
    local = served_at.astimezone(ZoneInfo(tz))
    last_day = add_business_days(local.date(), cure_days, holidays)
    # Deadlines land at 23:59:59 local — the owner gets the whole final day.
    return datetime.combine(last_day, time(23, 59, 59), tzinfo=ZoneInfo(tz))

Returning the deadline as a timezone-aware datetime at end-of-day is deliberate: partial-day arithmetic is the single most common source of off-by-one disputes, and giving the owner the entire final calendar day is what the courts expect.

Permalink to this section Step 2 — Support Tolling and Pauses

A cure clock is not always monotonic. An owner files an appeal, a court grants a stay, or the office agrees to an extension — each tolls the deadline, pausing the clock and pushing the end date later by the paused span. Model tolling as a list of closed intervals and add their total business-day length back onto the deadline, so a paused case never escalates while it is legally frozen.

from dataclasses import dataclass, field


@dataclass(slots=True)
class TollingWindow:
    reason: str
    start: date
    end: date            # inclusive; the clock is frozen through this day


def business_days_between(a: date, b: date, holidays: frozenset[date]) -> int:
    """Count business days in the inclusive range [a, b]."""
    if b < a:
        return 0
    days, cur = 0, a
    while cur <= b:
        if cur.weekday() < 5 and cur not in holidays:
            days += 1
        cur += timedelta(days=1)
    return days


def tolled_deadline(base: datetime, tolls: list[TollingWindow],
                    holidays: frozenset[date], tz: str) -> datetime:
    """Extend the base deadline by the business-day length of each tolling window."""
    paused_days = sum(
        business_days_between(t.start, t.end, holidays) for t in tolls
    )
    if paused_days == 0:
        return base
    shifted = add_business_days(base.astimezone(ZoneInfo(tz)).date(),
                                paused_days, holidays)
    return datetime.combine(shifted, time(23, 59, 59), tzinfo=ZoneInfo(tz))

Persisting the tolling windows rather than just the final number means an appeal can reconstruct why a deadline moved — “paused 5 business days for the appeal filed on the 3rd” — which is exactly the explanation a hearing officer asks for.

Permalink to this section Step 3 — Schedule the Automatic Escalation Job

A deadline is inert until something acts on it. Run a periodic job — a Celery beat task, a cron-driven management command, or an APScheduler interval — that wakes up each business morning, finds every case whose tolled deadline has lapsed without a passing re-inspection, and drives the LAPSE/escalation transition through the lifecycle engine. Make the sweep idempotent so a re-run never double-escalates.

from datetime import timezone
import structlog

log = structlog.get_logger("violation.escalation")


def sweep_escalations(session, now: datetime | None = None) -> int:
    """Find lapsed, unresolved cases and escalate them. Safe to run repeatedly."""
    now = now or datetime.now(timezone.utc)
    due = (
        session.query(CaseRow)
        .filter(CaseRow.state == "cure_period")
        .filter(CaseRow.cure_deadline.isnot(None))
        .filter(CaseRow.cure_deadline <= now)   # deadline already passed
        .all()
    )
    escalated = 0
    for row in due:
        case = load_case(session, row.case_id)   # rehydrate full ViolationCase
        try:
            apply(case, Event.LAPSE, actor="[email protected]",
                  reason=f"cure deadline {row.cure_deadline.isoformat()} lapsed",
                  now=now)
            commit_transition(session, case)     # atomic state + audit row
            escalated += 1
        except GuardFailed as exc:
            # Deadline moved (late tolling) between query and apply — skip, retry next sweep.
            log.warning("escalation_skipped", case_id=row.case_id, reason=str(exc))
    log.info("escalation_sweep", scanned=len(due), escalated=escalated)
    return escalated

The GuardFailed catch is what makes the sweep safe against a race: if a tolling window was recorded after the query but before apply, the lifecycle guard refuses the move and the case simply waits for the next sweep instead of escalating illegally. The apply, Event, GuardFailed, and commit_transition symbols all come from the violation lifecycle engine; this job is the scheduler that drives it.

Permalink to this section Step 4 — Expose SLA-Style Timers for the Queue View

Officers need to see how long is left before a case escalates, not just whether it has. Compute a signed remaining-business-days value per open case so the enforcement queue can sort by urgency and flag cases entering their final window — the same SLA-timer pattern used for support tickets, applied to statutory deadlines.

def business_days_remaining(deadline: datetime, holidays: frozenset[date],
                            tz: str, now: datetime | None = None) -> int:
    """Positive = days left; zero/negative = overdue by that many business days."""
    now = (now or datetime.now(timezone.utc)).astimezone(ZoneInfo(tz))
    today, target = now.date(), deadline.astimezone(ZoneInfo(tz)).date()
    if target >= today:
        return business_days_between(today, target, holidays) - 1  # exclude today
    return -business_days_between(target, today, holidays)          # overdue

Surfacing this number turns the queue from a flat list into a prioritized worklist, and a case crossing from 1 to 0 is the visible cue that tomorrow’s sweep will escalate it unless a re-inspection lands first.

Permalink to this section Parameter and Flag Reference

Parameter Type Default Notes
cure_days int 10 Ordinance-defined; business days unless the code explicitly says calendar days.
holidays frozenset[date] frozenset() Load the jurisdiction’s observed dates yearly; a missing holiday escalates early.
tz str "America/New_York" The municipality’s local zone; never compute deadlines in UTC-local mismatch.
deadline_time time 23:59:59 Owner gets the whole final day; avoids partial-day off-by-one disputes.
sweep_interval str "business-daily" Run each business morning; more frequent is fine because the sweep is idempotent.
escalate_actor str "[email protected]" System actor recorded on auto-escalations for a clean audit trail.
grace_business_days int 0 Optional buffer past the deadline before escalating; keep at 0 unless ordinance allows.
max_tolling_days int 90 Cap total tolling to catch a runaway pause left open by mistake.

Permalink to this section Common Failure Patterns and Fixes

Permalink to this section Weekends or holidays counted against the window

A naive served + timedelta(days=cure_days) counts non-working days, shortening the owner’s statutory window and voiding any downstream fine. Roll forward one working day at a time as in Step 1, and load the jurisdiction’s holiday set every year — a hardcoded list silently drifts wrong each January.

Permalink to this section Timezone drift moving the deadline by a day

Computing on served_at in UTC while the ordinance means local time can shift a late-afternoon notice into the next day. Convert to the municipality’s ZoneInfo before extracting the date, and land the deadline at end-of-day local so a datetime comparison never trips on an hours-level difference.

Permalink to this section Escalating a case that is legally tolled

A sweep that reads only cure_deadline will escalate a case whose clock a court has paused. Fold tolling into the persisted deadline (Step 2) and keep the lifecycle guard on the LAPSE transition, so a paused case is refused escalation even if a stale deadline slips through the query.

Permalink to this section Double-escalation on a re-run

A scheduler that retries after a partial failure can escalate the same case twice, corrupting the audit trail. Filter the sweep on state == "cure_period" so an already-escalated case is out of scope, and rely on the lifecycle engine rejecting a repeat LAPSE from ESCALATED as an illegal transition.

Permalink to this section Deadline computed once and never refreshed

Storing only the final deadline and never recomputing means a holiday added mid-cycle, or a late tolling order, never takes effect. Recompute from the served date, cure length, holidays, and current tolling windows whenever any input changes, and persist the inputs alongside the result so the number is always reproducible.

Permalink to this section Audit and Logging Guidance

Every deadline computation and every escalation must be reconstructable, because both are legal acts an owner can appeal. Log the served-notice timestamp, the cure length, the holiday set version, the timezone, and each tolling window with its reason and span — not just the final deadline — so a hearing officer can recompute the date independently. When the sweep escalates a case, record the system actor, the exact lapsed deadline, and the fact that no passing re-inspection existed at sweep time; write it through the same hash-chained, append-only audit path the lifecycle engine uses, so the escalation entry chains onto the case’s transition history. Keep these records for the full state-mandated retention period, and alert when a case’s business_days_remaining crosses zero without a scheduled re-inspection — that gap is the earliest signal that an owner is heading to a hearing. Never log raw personal contact details from the notice into the timeline audit; keep it to case identifiers and dates so the trail itself is not a privacy leak.

Permalink to this section Frequently Asked Questions

Permalink to this section Should cure periods be counted in business days or calendar days?

Follow the ordinance verbatim: many codes specify business days, some specify calendar days, and a few switch based on the violation class. Default to business days with a holiday calendar because that is the stricter, more common reading, but make the unit a per-jurisdiction configuration flag rather than a hardcoded assumption — guessing wrong in either direction is a due-process problem.

Permalink to this section How do I handle a holiday that is added or moved mid-year?

Load the holiday set from data, versioned by year, and recompute open deadlines from their stored inputs rather than caching a final date. Persisting the served date, cure length, and holiday-set version means a corrected calendar propagates to every affected case on the next recompute, and the audit log still shows which holiday version produced the earlier number.

Permalink to this section What stops the escalation job from firing on a paused case?

Two layers. The deadline itself is extended by any tolling windows before it is stored, so a paused case’s deadline is in the future and the sweep query does not select it. And the lifecycle engine’s guard on the LAPSE transition independently refuses to advance a case whose clock has not truly elapsed, so even a race between a late tolling order and the sweep resolves safely by skipping the case until the next run.