Core Architecture Regulatory Mapping

Rolling Deadlines Forward Across Weekends and State Holidays

This guide is part of the State Filing Deadline Calendars area within the Core Architecture & Regulatory Mapping framework. It picks up exactly where the derivation logic stops: a raw, unadjusted civil due date has been computed, and now it may land on a Saturday, a Sunday, or a state holiday that no filing office observes. This page is about applying the jurisdiction’s own business-day rule — usually roll-forward, occasionally roll-backward — against that jurisdiction’s holiday calendar, in that jurisdiction’s timezone, so the effective deadline the scheduler acts on is the one the statute actually enforces.

Scope of This Page

This page covers business-day adjustment and only that. It takes a raw due date and a per-jurisdiction holiday set and returns the adjusted, filable date, honouring weekend rules, observed-holiday shifts, and the in-period constraint that forces the rare roll-backward. It explicitly does not derive the raw date — that is the job of Modeling Anniversary vs. Fiscal-Year Filing Deadlines — and it does not decide what happens once the adjusted date passes, which is governed by Penalty Avoidance & Grace-Period Mapping. The input is a clean civil date; the output is a business-day-correct civil date the rest of the pipeline can trust.

The Constraint That Makes Roll Direction Statutory, Not Cosmetic

Whether a weekend or holiday collision rolls forward or backward is set by each jurisdiction’s law, and getting it wrong shifts a real deadline by a real day. Most states codify a forward roll: New York extends any deadline that falls on a Saturday, Sunday, or public holiday to the next business day under N.Y. Gen. Constr. Law § 25-a, and Texas moves a franchise-tax due date off a weekend or legal holiday to the next business day for its combined report under Tex. Tax Code § 171. California extends a deadline landing on a holiday to the next business day under Cal. Gov. Code § 6707, though its anniversary-month window under Cal. Corp. Code § 1502 usually absorbs the collision before adjustment is even needed. Delaware is the sharp exception: the March 1 franchise-tax date under 8 Del. C. § 502 is statutorily fixed, and while the Division of Corporations will accept a filing on the following business day, the penalty clock still starts on the 1st — so treating Delaware like a forward-roll state under-reports the true exposure. And because a deadline is a civil date in the filing state, every one of these tests must run in the jurisdiction’s own timezone; a UTC comparison flips the day near midnight and rolls against the wrong calendar.

Weekend and holiday roll-forward, plus the in-period roll-backward, resolved in the jurisdiction's timezone Three day-strip scenarios: a Saturday due date rolls forward two days to Monday; a Monday-holiday due date rolls forward to Tuesday; and a Friday-holiday due date at a period boundary rolls backward to Thursday to avoid crossing into the next filing period. Every test runs on civil dates in the jurisdiction's own timezone. roll the raw due date to a business day — in the jurisdiction's own civil timezone A · Weekend TX · May 15 2027 roll-forward (+2d) Fri 5/14 Sat 5/15 raw Sun 5/16 Mon 5/17 due Sat → next business day effective 2027-05-17 B · Holiday Mon holiday roll-forward (+1d) Sat 1/16 Sun 1/17 Mon 1/18 hol Tue 1/19 due observed holiday → skip forward effective 2027-01-19 C · Boundary Fri holiday, month-end roll-backward (−1d) Thu 12/30 due Fri 12/31 hol Sat 1/1 Mon 1/3 ✗ next mo. forward exits period → roll back effective 2027-12-30

Prerequisites

  • Python 3.10+ — for X | Y unions and modern zoneinfo support in the standard library.
  • Standard library only: datetime, zoneinfo (IANA zones so each jurisdiction resolves in its own civil time), json, logging, and dataclasses.
  • A raw civil due date produced upstream by the derivation layer; this module never computes the anchor, only adjusts it.
  • A per-jurisdiction observed-holiday set — dates already shifted for weekend observation (e.g. a Saturday federal holiday observed the preceding Friday) — kept in version-controlled data so an audit can reconstruct which calendar produced a given adjustment.
  • The statutory filing period (usually the due month) when a jurisdiction requires the adjusted date to stay in-period, which is what triggers the roll-backward branch.

Implementation: A Timezone-Aware Business-Day Roller

The module below is one HolidayCalendar per jurisdiction plus a pure roll_to_business_day. It walks off a non-business day in the jurisdiction’s declared direction, guards against an infinite loop on a malformed holiday set, and applies the in-period constraint that turns a forward roll into a backward one when the next business day would escape the filing month. Holiday observation (Saturday to Friday, Sunday to Monday) is applied when the set is built via observe, and civil_today proves the “today” comparison is done in the filing state’s zone, never UTC. Every adjustment emits one structured JSON record noting whether the raw date hit a weekend or a holiday and how far it moved.

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from enum import Enum
from zoneinfo import ZoneInfo

logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("calendar.rollforward")

SATURDAY, SUNDAY = 5, 6


class RollDirection(str, Enum):
    FORWARD = "forward"     # next business day — the common statutory rule
    BACKWARD = "backward"   # previous business day — used to keep a date in-period


def observe(nominal: date) -> date:
    """Apply weekend observation to a nominal holiday (5 U.S.C. 6103 style):
    a Saturday holiday is observed the preceding Friday, a Sunday holiday the
    following Monday. Build holiday sets through this so collisions are pre-shifted."""
    if nominal.weekday() == SATURDAY:
        return nominal - timedelta(days=1)
    if nominal.weekday() == SUNDAY:
        return nominal + timedelta(days=1)
    return nominal


@dataclass(frozen=True)
class HolidayCalendar:
    jurisdiction: str
    timezone: str                       # IANA zone; a deadline is a civil date HERE, not in UTC
    holidays: frozenset[date]           # observed dates, already weekend-shifted via observe()
    direction: RollDirection = RollDirection.FORWARD

    def is_business_day(self, d: date) -> bool:
        if d.weekday() == SATURDAY or d.weekday() == SUNDAY:
            return False
        return d not in self.holidays   # a filing office is closed on its observed holidays


def civil_today(cal: HolidayCalendar) -> date:
    """Today's civil date in the jurisdiction's own zone — never derived from UTC."""
    return datetime.now(ZoneInfo(cal.timezone)).date()


def roll_to_business_day(raw_due: date, cal: HolidayCalendar,
                         period_month: int | None = None) -> date:
    """Adjust `raw_due` to a filable business day per the jurisdiction's own rule.

    Walks in the calendar's declared direction until a business day is found. If a
    FORWARD roll would leave `period_month` (cross into the next month), it falls back
    to a BACKWARD roll so the filing stays inside the statutory period.
    """
    if cal.is_business_day(raw_due):
        return raw_due  # nothing to do: already a business day

    step = timedelta(days=1 if cal.direction is RollDirection.FORWARD else -1)
    adjusted, guard = raw_due, 0
    while not cal.is_business_day(adjusted):
        adjusted += step
        guard += 1
        if guard > 14:  # defensive: a malformed holiday set must never spin forever
            raise RuntimeError(f"{cal.jurisdiction}: no business day within 14 days of {raw_due}")

    # In-period guard: a forward roll must not escape the statutory filing month.
    if (cal.direction is RollDirection.FORWARD
            and period_month is not None
            and adjusted.month != period_month):
        adjusted = raw_due
        while not cal.is_business_day(adjusted):
            adjusted -= timedelta(days=1)  # roll backward to stay in-period

    shift = (adjusted - raw_due).days
    logger.info(json.dumps({
        "event": "deadline_rolled",
        "jurisdiction": cal.jurisdiction,
        "timezone": cal.timezone,
        "raw_due": raw_due.isoformat(),
        "effective_due": adjusted.isoformat(),
        "shift_days": shift,
        # Report the direction actually applied: the in-period guard can reverse it.
        "direction": "forward" if shift > 0 else "backward" if shift < 0 else "none",
        "landed_on_holiday": raw_due in cal.holidays,
        "landed_on_weekend": raw_due.weekday() >= SATURDAY,
    }))
    return adjusted


REGISTRY: dict[str, HolidayCalendar] = {
    # Delaware franchise tax (8 Del. C. 502) is a fixed date; the penalty clock does not
    # roll, so no forward adjustment is modeled here even though the office reopens later.
    "US-TX": HolidayCalendar("US-TX", "America/Chicago", frozenset({
        observe(date(2027, 1, 1)), date(2027, 1, 18),          # New Year, MLK Day
        date(2027, 3, 2), observe(date(2027, 6, 19)),          # TX Independence Day, Juneteenth
    })),
    "US-NY": HolidayCalendar("US-NY", "America/New_York", frozenset({
        observe(date(2027, 1, 1)), date(2027, 1, 18),
        observe(date(2028, 1, 1)),                             # 2028 New Year observed Fri 12/31
    })),
    "US-CA": HolidayCalendar("US-CA", "America/Los_Angeles", frozenset({
        observe(date(2027, 1, 1)), date(2027, 1, 18), date(2027, 3, 31),  # + Cesar Chavez Day
    })),
}


if __name__ == "__main__":
    tx = REGISTRY["US-TX"]
    roll_to_business_day(date(2027, 5, 15), tx, period_month=5)   # Sat -> Mon 2027-05-17
    ny = REGISTRY["US-NY"]
    roll_to_business_day(date(2027, 12, 31), ny, period_month=12)  # Fri holiday -> Thu 2027-12-30

The Texas call rolls the Saturday May 15 franchise-tax date forward to Monday May 17, a two-day shift that the priority scoring algorithms measure proximity against. The New York call is the roll-backward edge case: December 31 2027 is the observed New Year holiday (January 1 2028 falls on a Saturday), a forward roll lands on Monday January 3 in the next year, so the period_month guard reverses direction and settles on Thursday December 30 to keep the filing in-period. Delaware is intentionally absent from the roller because its § 502 date does not move — modeling it as a forward-roll state would understate the penalty exposure that Penalty Avoidance & Grace-Period Mapping tracks.

Configuration Reference

The roll rule and its inputs are data because each is set by a specific statute or filing-office practice, not by engineering preference.

Field Example Statutory / operational justification
timezone America/Chicago (TX) A deadline is a civil date in the filing state; every business-day test runs in this IANA zone so a near-midnight comparison never flips to the wrong day.
holidays observed 2027 set per state Only dates the filing office actually observes count; the set is weekend-shifted at build time via observe so lookups are exact.
direction FORWARD (NY, TX, CA) N.Y. Gen. Constr. Law § 25-a and Tex. Tax Code § 171 roll a weekend/holiday deadline to the next business day; the field lets a jurisdiction override the default.
period_month (argument) 12 for a December date Triggers the roll-backward guard: if a forward roll crosses out of the statutory filing month, the adjustment reverses to stay in-period.
observe() shift Sat holiday → Fri Applies 5 U.S.C. § 6103-style weekend observation when building the set, matching how filing offices actually close.
Delaware exclusion no entry in REGISTRY 8 Del. C. § 502 fixes March 1; the penalty clock does not roll, so no forward adjustment is applied.

Failure Modes and Fallback Routing

Each adjustment fault maps onto the parent area’s error taxonomy — the same DATA_VALIDATION_FAILURE, STATUTORY_AMBIGUITY, and INTEGRATION_TIMEOUT categories the State Filing Deadline Calendars engine escalates on — and the roller responds to each differently.

  1. A malformed or empty holiday region makes every day look non-business (data-validation, terminal). A holiday set that accidentally includes weekdays in a run, or a direction that walks into a bad region, could loop forever. The 14-day guard raises a RuntimeError with the jurisdiction and raw date instead of spinning, and the obligation routes to manual review rather than silently stalling the batch.
  2. The comparison runs in UTC instead of the jurisdiction’s zone (system, non-reproducible). A due date resolved against datetime.utcnow() can read as a different civil day near midnight and roll against the wrong weekend. civil_today forces the “today” reference through ZoneInfo(cal.timezone); the raw due date itself is a naive civil date, so no arithmetic ever crosses a timezone or DST boundary. This is the timezone concern the Modeling Anniversary vs. Fiscal-Year Filing Deadlines layer hands off cleanly.
  3. A forward roll silently escapes the filing period (statutory ambiguity). Without the period_month guard, a year-end holiday collision rolls into January and reports a deadline in the wrong statutory period. The guard reverses to a backward roll; if a jurisdiction’s rule on the boundary is genuinely unsettled, the case escalates to legal-ops with both candidate dates attached rather than guessing.
  4. Delaware is treated as a roll-forward state (statutory ambiguity, under-reporting). Applying a forward roll to the § 502 March 1 date reports a later effective deadline than the statute allows and understates penalty exposure. Delaware is excluded from the roller by design, and its fixed date flows straight to the penalty layer without adjustment.

Frequently Asked Questions

When does a deadline roll backward instead of forward?

Only when a forward roll would carry the date out of the statutory filing period — almost always a year-end or month-end boundary. If the raw due date is a Friday holiday at month-end and the next business day is in the following month, rolling forward would report the deadline in the wrong period, so the period_month guard reverses direction and lands on the preceding business day instead. Pass the statutory month as period_month to arm the guard; without it, the roller always moves forward. Backward rolls are the exception, not the norm, and they exist to keep the filing in-period.

Why must the business-day test run in the jurisdiction's timezone?

A filing deadline is a civil date in the filing state, not a UTC instant, and the two disagree near midnight. If you resolve “today” or the due date against UTC, a Texas deadline can read as the next day and roll against the wrong weekend or holiday. The roller keeps the raw due date as a naive civil date and derives any “today” comparison through ZoneInfo(cal.timezone) with civil_today, so no arithmetic ever crosses a timezone or daylight-saving boundary and the adjusted date is always the one the state actually enforces.

Why is Delaware excluded from the roll-forward calculator?

Because 8 Del. C. § 502 fixes the franchise-tax and annual-report deadline at March 1 and the penalty clock does not roll. The Division of Corporations will accept a filing on the next business day if the 1st is a weekend, but the statutory exposure still begins on the 1st. Modeling Delaware as a forward-roll state would report a later effective deadline than the law allows and understate the penalty the Penalty Avoidance & Grace-Period Mapping layer needs to see, so its fixed date passes through unadjusted.

How do observed holidays differ from the nominal holiday date?

A nominal holiday that falls on a weekend is observed on an adjacent weekday: a Saturday holiday is observed the preceding Friday and a Sunday holiday the following Monday, following the 5 U.S.C. § 6103 pattern most filing offices track. The roller resolves this once, at set-construction time, by passing each nominal date through observe, so the holidays frozenset already contains the shifted dates and every is_business_day lookup is an exact membership test rather than a runtime calculation.