Calculating New York FOIL Acknowledgment and Response Deadlines in Python

Within State Law Compliance Frameworks, New York’s Freedom of Information Law (FOIL) imposes a two-stage business-day clock that trips up automations built for single-deadline statutes: an agency must first acknowledge a request within a short window, and then either grant it, deny it, or commit to a reasonable date by which it will respond. Compute either stage wrong and the request slides into a constructive denial that the requester can appeal. This page builds a Python module that models both FOIL clocks under Public Officers Law § 89(3), applies holiday-aware business-day math with New York state observances, and records the constructive-denial boundary in an append-only audit trail.

The FOIL Statutory Clock

New York Public Officers Law § 89(3)(a) sets the acknowledgment stage: within 5 business days of receiving a request, an agency must either make the records available, deny the request in writing, or furnish a written acknowledgment of receipt. The acknowledgment is not the response — it is a required first touch that also, where more time is needed, states an approximate date by which the agency will grant or deny access.

The response stage follows from the same subsection. When an agency acknowledges within five business days but cannot yet grant or deny, it must supply a date certain that is reasonable under the circumstances. For an ordinary, well-defined request, guidance and case law treat roughly 20 business days from acknowledgment as the outer edge of reasonableness; genuinely voluminous or complex requests can justify a later date, provided the agency explains it. The automation therefore computes two figures: the hard 5-business-day acknowledgment deadline, and a 20-business-day reasonable-response marker that flags when an agency’s committed date starts to look unreasonable.

The consequence of missing either stage is the concept that makes FOIL timing matter. Under § 89(4)(a) and § 89(3), an agency’s failure to acknowledge within five business days, its failure to respond by the reasonable date it gave, or an unreasonable delay, can amount to a constructive denial — a denial the requester may treat as final and carry to an administrative appeal without waiting further. Modeling that boundary is what lets escalation logic in Deadline Tracking & Escalation act before the agency drifts into a denial it never intended.

Prerequisites & Environment Setup

The module is standard-library only, with no third-party runtime dependency, so it runs safely inline in the request path.

  • Python 3.11+ — for datetime.date, frozenset[date] typing, and frozen slotted dataclasses.
  • Standard library only: dataclasses, datetime, json, logging.
  • A version-controlled holiday set — New York state holidays, including Juneteenth, are the skip-days for the business-day count; pin one frozenset per year and review it against the published schedule.
  • A structured, append-only log sink — stdout JSON in development, forwarded to your append-only store or SIEM in production so the acknowledgment, reasonable-response, and constructive-denial dates are all reconstructable on appeal.

Keep the holiday set beside the code under version control. A missing observance shortens the business-day count and can make an on-time acknowledgment look late — or, worse, mask a constructive-denial boundary the agency has already crossed.

Modeling the Two-Stage Clock

The diagram shows the two overlapping FOIL windows measured from the receipt date, and the point at which a lack of timely response becomes a constructive denial.

New York FOIL acknowledgment and response timeline A timeline measured in business days from the receipt date. A top bar spans day zero to day five, the acknowledgment window under Public Officers Law section 89 subsection 3. A lower bar spans day zero to day twenty, the window to grant, deny, or state a reasonable date, generally not more than twenty business days for a routine request. A note marks that no timely response by the committed date becomes a constructive denial that the requester may appeal. Day 0 Day 5 Day 20 Acknowledge receipt 5 business days · § 89(3) Grant, deny, or state a reasonable date generally not more than 20 business days · § 89(3) No timely response = constructive denial (appealable)

The module records the acknowledgment deadline and the reasonable-response marker as distinct dates, so the audit trail distinguishes a genuine, communicated extension from silence that has hardened into a constructive denial.

Step-by-Step Implementation

The module defines a business-day helper, a frozen result record, a New York holiday set, and the deadline function computing both stages. The comments cite the governing FOIL provisions.

python
from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone


class JsonAuditFormatter(logging.Formatter):
    """Emit one structured JSON object per audit event (NIST SP 800-53 AU-3)."""

    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "event": record.getMessage(),
        }
        for key in ("request_id", "statute", "acknowledge_by", "response_by"):
            if hasattr(record, key):
                payload[key] = getattr(record, key)
        return json.dumps(payload)


logger = logging.getLogger("foil_deadline")
logger.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(JsonAuditFormatter())
logger.addHandler(_handler)


# New York state holidays for the FOIL business-day count (2026 instances). N.Y.
# Pub. Off. Law § 89(3) counts the acknowledgment and response windows in business
# days, which exclude weekends and observed state holidays.
NY_STATE_HOLIDAYS_2026: frozenset[date] = frozenset({
    date(2026, 1, 1),    # New Year's Day
    date(2026, 1, 19),   # Dr. Martin Luther King Jr. Day
    date(2026, 2, 12),   # Lincoln's Birthday
    date(2026, 2, 16),   # Washington's Birthday
    date(2026, 5, 25),   # Memorial Day
    date(2026, 6, 19),   # Juneteenth
    date(2026, 7, 3),    # Independence Day (observed)
    date(2026, 9, 7),    # Labor Day
    date(2026, 10, 12),  # Columbus Day
    date(2026, 11, 11),  # Veterans Day
    date(2026, 11, 26),  # Thanksgiving Day
    date(2026, 12, 25),  # Christmas Day
})


@dataclass(frozen=True, slots=True)
class FoilDeadline:
    """Immutable record of one FOIL acknowledgment/response calculation."""
    request_id: str
    received: date
    statute: str
    acknowledge_by: date           # § 89(3): 5 business days
    reasonable_response_by: date    # outer edge of a reasonable date for a routine request


def add_business_days(start: date, n: int, holidays: frozenset[date]) -> date:
    """Add n business days, skipping weekends and observed New York holidays.

    N.Y. Pub. Off. Law § 89(3) measures FOIL windows in business days; a day counts
    only if it is a weekday and not an observed state holiday.
    """
    day = start
    counted = 0
    while counted < n:
        day += timedelta(days=1)
        if day.weekday() < 5 and day not in holidays:
            counted += 1
    return day


def foil_deadlines(
    request_id: str,
    received: date,
    holidays: frozenset[date] = NY_STATE_HOLIDAYS_2026,
) -> FoilDeadline:
    """Compute the FOIL acknowledgment and reasonable-response deadlines.

    N.Y. Pub. Off. Law § 89(3)(a): the agency must acknowledge a request within
    5 business days of receipt and either grant, deny, or state a reasonable date.
    For a routine request, roughly 20 business days is the outer edge of a
    reasonable date; failure to respond by the committed date can be a constructive
    denial under § 89(4)(a), appealable without further waiting.
    """
    try:
        if not isinstance(received, date):
            raise TypeError("received must be a datetime.date")

        # § 89(3): 5 business days to acknowledge (or grant/deny outright).
        acknowledge_by = add_business_days(received, 5, holidays)
        # Outer edge of a "reasonable date" for a routine request: 20 business days.
        reasonable_response_by = add_business_days(received, 20, holidays)

        record = FoilDeadline(
            request_id=request_id,
            received=received,
            statute="N.Y. Pub. Off. Law § 89(3)",
            acknowledge_by=acknowledge_by,
            reasonable_response_by=reasonable_response_by,
        )
        logger.info(
            "foil_deadline_computed",
            extra={
                "request_id": request_id,
                "statute": record.statute,
                "acknowledge_by": acknowledge_by.isoformat(),
                "response_by": reasonable_response_by.isoformat(),
            },
        )
        return record
    except Exception:
        # Fail loud: a miscomputed § 89(3) clock can mask a constructive denial.
        logger.error("foil_deadline_failed", extra={"request_id": request_id})
        raise

The record keeps the acknowledgment and reasonable-response dates separate because they answer different questions on audit: whether the agency made its required first touch, and whether the date it committed to still falls inside the window a reviewer would call reasonable.

Validation & Verification

Assert both windows, including a holiday inside the longer count. A request received Monday, June 1, 2026, must be acknowledged by June 8; its 20-business-day reasonable-response marker crosses Juneteenth (June 19) and therefore lands on June 30 rather than June 29.

python
if __name__ == "__main__":
    d = foil_deadlines("NY-2026-0311", date(2026, 6, 1))
    # 5 business days from Mon 2026-06-01 -> 2026-06-08 (no holiday in that span).
    assert d.acknowledge_by == date(2026, 6, 8)
    # 20 business days from Mon 2026-06-01, skipping Juneteenth 2026-06-19,
    # lands on 2026-06-30 (one day later than it would without the holiday).
    assert d.reasonable_response_by == date(2026, 6, 30)
    print("FOIL deadline checks passed")

Because FoilDeadline is frozen, replaying a request during recovery reproduces both dates exactly and never opens a second acknowledgment window. Replay a corpus of historical requests spanning Juneteenth and the February observances, and diff the computed dates against a verified table before deploying. The append-only trail feeds the Audit Logging Architecture an appeal reviewer will read.

Troubleshooting & Edge Cases

  • Counting FOIL windows in calendar days. Both § 89(3) windows are business days; calendar-day math misstates them. Diagnosis: New York deadlines matching a calendar-day jurisdiction like the California Public Records Act model. Fix: use the business-day helper and a reviewed holiday set.
  • Treating acknowledgment as the response. A five-business-day acknowledgment is not a grant or denial; conflating them hides the real response obligation. Diagnosis: requests marked “answered” at acknowledgment that never received records. Fix: model the acknowledgment and the reasonable-response marker as distinct dates, as the module does.
  • Missing the constructive-denial boundary. Silence past the committed date can be treated as a denial. Diagnosis: a request stalled with no appeal path surfaced. Fix: flag when the reasonable-response marker passes without a grant or denial and hand the boundary to escalation.
  • A holiday missing from the set. A dropped Juneteenth or February observance shortens the count. Diagnosis: a 20-business-day date one day early. Fix: pin and review the holiday frozenset yearly and assert coverage in tests.
  • Assuming 20 business days is a hard cap. For voluminous or complex requests, a later committed date can be reasonable if the agency explains it. Fix: treat the 20-business-day figure as a flag for review, not an automatic violation, mirroring the reasonableness standard the statute uses — much like the “promptly” standard in the Texas Public Information Act model.

Compliance Verification Checklist

FAQ

What is the 5-business-day rule under New York FOIL?

Public Officers Law § 89(3)(a) requires an agency, within 5 business days of receiving a FOIL request, to either make the records available, deny the request in writing, or furnish a written acknowledgment of receipt. Where the agency needs more time, that acknowledgment must also state an approximate date by which it will grant or deny access. The five-business-day figure excludes weekends and observed state holidays, so an accurate holiday set is essential to computing it correctly.

How long does an agency have to actually respond after acknowledging?

FOIL requires a reasonable date, not a single fixed number. For a routine, well-defined request, guidance treats roughly 20 business days from acknowledgment as the outer edge of reasonableness, while genuinely voluminous or complex requests can justify a later committed date if the agency explains it. The module computes the 20-business-day marker as a flag for review rather than a hard violation, so a legitimately complex request is not miscoded as late.

What is a constructive denial under FOIL?

A constructive denial is when an agency’s failure to act — not acknowledging within five business days, not responding by the reasonable date it committed to, or unreasonably delaying — is treated as a denial the requester can appeal without waiting further, under § 89(4)(a). Modeling the boundary explicitly matters because it is the point at which an appeal clock effectively begins; surfacing it lets escalation intervene before an agency drifts into a denial it never intended to issue.