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
frozensetper 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.
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.
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.
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
frozensetyearly 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.
Related
- State Law Compliance Frameworks — the jurisdiction-rule layer this calculation plugs into
- Calculating California Public Records Act Deadlines — the calendar-day determination-window counterpart
- Calculating Texas Public Information Act Deadlines — the Attorney-General-tolling business-day counterpart
- Deadline Tracking & Escalation — consumes these deadlines and the constructive-denial boundary to drive escalation
- Computing Business-Day FOIA Deadlines with Federal Holiday Calendars — the general business-day engine behind this count