Modeling RFI Response SLA Deadlines in Python
A construction contract almost always fixes how long the design team has to answer a Request for Information — commonly ten business days — and a late answer is the documented predicate for a time extension. But that entitlement only holds if the clock is defensible. This page solves one precise problem: how to model an RFI’s contractual response SLA in Python — timezone-aware ISO 8601 deadlines, business-day versus calendar-day clocks, breach detection, and escalation — so that a missed response has a defensible, timestamped basis for a time extension. The model pins the submission instant to a real UTC offset, computes the due instant by the contract’s own clock convention, classifies the record as on-track, imminent, or breached, and routes a breach for escalation with the evidence attached. It is a concrete slice of RFI schema design within a deterministic construction data architecture and taxonomy, and it targets Python automation builders and project engineers who need SLA math that survives a claims review.
Key Rules and Specification
An SLA clock is only defensible if every instant it touches is unambiguous. These rules govern the model:
- Timestamps are timezone-aware, always.
created_atanddue_atcarry a real UTC offset per ISO 8601. A naive timestamp is rejected at construction time, because subtracting a naive from an aware datetime raises, and — worse — a portal that emits local time without an offset would silently mis-date the deadline across project sites in different zones. - The clock convention is part of the contract. A business-day clock advances only on working days, skipping weekends and a configured holiday calendar; a calendar-day clock adds days directly. The convention is an explicit
Literalfield, never inferred, because a ten-business-day and a ten-calendar-day deadline can differ by four days or more. - Business days count forward from the day after submission. Day zero is the submission day; the first business day is the next working day. The walk skips Saturdays, Sundays, and holidays, and lands
due_atat the same wall-clock time as submission in the original zone. - Breach is a comparison against a trusted
now. Pass an explicit timezone-awarenow(never an implicitdatetime.now()buried in the logic) so the classification is reproducible in a test and in a claims review. A record is breached the instantnowpassesdue_at. - Severity uses the site-canonical bands. The fraction of the SLA window consumed is scored against the same numbers used everywhere: below 0.75 is on-track, 0.75 to 0.92 is on-watch, 0.92 and above is imminent, and past the deadline is breached — the only state that escalates.
- Discipline and status are closed vocabularies. The RFI carries a discipline drawn from a
LiteralofARCH,STR,MEP,CIV,ELEC,PLMBand a statusLiteral, so SLA reporting aggregates cleanly and a typo can never create a phantom bucket.
| Consumed fraction | Severity | Action |
|---|---|---|
< 0.75 |
on_track |
none |
0.75 – 0.92 |
on_watch |
notify assignee |
≥ 0.92 (before due) |
imminent |
warn approver |
now > due_at |
breached |
escalate to fallback alert router |
Production Code Example
The model below is a strict Pydantic v2 contract: both instants are validated timezone-aware in a model_validator(mode="after"), the discipline and clock convention are Literal vocabularies, and status is a closed set. The deadline is never stored loosely — it is computed once by an explicit calculator so the convention that produced it is auditable.
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from enum import Enum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("rfi_sla")
# Discipline is a controlled vocabulary, never a free string.
Discipline = Literal["ARCH", "STR", "MEP", "CIV", "ELEC", "PLMB"]
ClockConvention = Literal["business", "calendar"]
Severity = Literal["on_track", "on_watch", "imminent", "breached"]
# Site-canonical bands, applied here to the fraction of the SLA window consumed.
IMMINENT = 0.92 # >= 0.92 of the window elapsed: warn the approver
ON_WATCH = 0.75 # 0.75–0.92 elapsed: notify the assignee
class RFIStatus(str, Enum):
SUBMITTED = "Submitted"
IN_REVIEW = "In Review"
ANSWERED = "Answered"
CLOSED = "Closed"
class RFISLA(BaseModel):
"""Contractual response-SLA contract for a single RFI, with a defensible clock."""
model_config = ConfigDict(frozen=True)
rfi_number: str = Field(..., pattern=r"^RFI-\d{4}-\d{3}$")
discipline: Discipline
status: RFIStatus
created_at: datetime # timezone-aware submission instant (ISO 8601)
due_at: datetime # timezone-aware contractual deadline
clock: ClockConvention
responded_at: datetime | None = None
@model_validator(mode="after")
def _validate_instants(self) -> RFISLA:
# Naive timestamps corrupt SLA math across project time zones — reject them.
for label, value in (("created_at", self.created_at),
("due_at", self.due_at),
("responded_at", self.responded_at)):
if value is not None and value.tzinfo is None:
raise ValueError(f"{label} must be timezone-aware (ISO 8601)")
if self.due_at <= self.created_at:
raise ValueError("due_at must fall after created_at")
return selfThe deadline calculator is the load-bearing piece. It advances one working day at a time so a holiday landing mid-window pushes the deadline correctly, and it re-attaches the original wall-clock time so due_at is the contractual close of business in the submission’s own zone.
def business_day_deadline(
created_at: datetime,
response_days: int,
holidays: frozenset[datetime.date] | None = None,
) -> datetime:
"""Deadline counting only working days from the day after submission.
Day zero is the submission day; counting starts the next calendar day and
skips Saturdays, Sundays, and any configured holiday. The result keeps the
submission's UTC offset, so it reads as the same wall-clock time in-zone.
"""
if created_at.tzinfo is None:
raise ValueError("created_at must be timezone-aware")
holidays = holidays or frozenset()
cursor = created_at
counted = 0
while counted < response_days:
cursor = cursor + timedelta(days=1)
if cursor.weekday() < 5 and cursor.date() not in holidays: # Mon–Fri, not a holiday
counted += 1
return cursor
def calendar_day_deadline(created_at: datetime, response_days: int) -> datetime:
"""Deadline that adds calendar days directly, offset preserved."""
if created_at.tzinfo is None:
raise ValueError("created_at must be timezone-aware")
return created_at + timedelta(days=response_days)
def deadline_for(created_at: datetime, response_days: int, clock: ClockConvention,
holidays: frozenset[datetime.date] | None = None) -> datetime:
"""Dispatch on the contractual clock convention. Never infer it."""
if clock == "business":
return business_day_deadline(created_at, response_days, holidays)
return calendar_day_deadline(created_at, response_days)Breach classification takes an explicit now so the outcome is reproducible, scores the consumed fraction against the canonical bands, and — when the deadline has passed with no answer — routes to escalation with the three instants attached, which is exactly the evidence a time-extension claim needs.
def classify_breach(sla: RFISLA, now: datetime) -> Severity:
"""Classify SLA health at an explicit, timezone-aware `now`."""
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
# An answered RFI stops its clock at the response instant.
stop = sla.responded_at or now
if stop > sla.due_at:
return "breached"
window = (sla.due_at - sla.created_at).total_seconds()
consumed = (stop - sla.created_at).total_seconds() / window
if consumed >= IMMINENT:
return "imminent"
if consumed >= ON_WATCH:
return "on_watch"
return "on_track"
def route_sla(sla: RFISLA, now: datetime) -> Severity:
"""Classify and escalate a breach with a defensible, timestamped basis."""
severity = classify_breach(sla, now)
if severity == "breached":
logger.warning(
"ESCALATE | %s breached SLA | created=%s due=%s now=%s | -> fallback alert router",
sla.rfi_number, sla.created_at.isoformat(), sla.due_at.isoformat(), now.isoformat(),
)
return severity
if __name__ == "__main__":
from datetime import timezone
central = timezone(timedelta(hours=-5))
created = datetime(2026, 7, 15, 9, 0, tzinfo=central)
due = deadline_for(created, response_days=10, clock="business",
holidays=frozenset({datetime(2026, 7, 20).date()}))
sla = RFISLA(
rfi_number="RFI-2026-014", discipline="ELEC", status=RFIStatus.IN_REVIEW,
created_at=created, due_at=due, clock="business",
)
now = datetime(2026, 7, 31, 12, 0, tzinfo=central)
print(f"due_at: {sla.due_at.isoformat()}")
print(f"severity: {route_sla(sla, now)}")Common Mistakes and Gotchas
Naive timestamps that silently mis-date the deadline. A portal that emits 2026-07-15T09:00:00 with no offset validates against a loose schema and then computes a deadline in whatever zone the server happens to run in. Under a claims review that ambiguity is fatal. Reject naive datetimes at the boundary, normalize to a known offset in extraction, and keep the original string in the audit trail so the submission instant is never in dispute.
Confusing business days with calendar days. Ten business days from a Wednesday, with one holiday in the window, is not “Wednesday plus ten days” — it can be four or more calendar days later. Storing the convention as an inferred default rather than an explicit contract field is how a deadline drifts and an otherwise valid time extension gets denied. Make clock a required Literal and compute the deadline through the dispatcher every time.
Classifying breach against an implicit now. Burying datetime.now() inside the classifier makes the result untestable and non-reproducible: the same record classifies differently every second, and a claims reviewer cannot re-derive the state. Pass an explicit timezone-aware now, so the classification is a pure function of its inputs and a breached record hands schedule impact logging a stable, timestamped basis to bind the delay to the schedule.
Where This Fits in the Pipeline
This SLA model is the time dimension of RFI schema design under the construction data architecture and taxonomy: the schema pins down identity, classification, and quantified impact, while this layer pins down when the answer is contractually due and whether it arrived in time. A breached record does not just raise an alert — it feeds schedule impact logging, which binds the documented delay to the affected activities so a critical-path slip is traceable to a specific late response. The escalation itself is owned by fallback alert routing, which applies the escalation policy to the breached record, and the same timezone-aware, ISO 8601 instants and closed vocabularies used here carry through to submittal metadata frameworks, so a submittal review clock and an RFI response clock speak the same language. Because the severity bands reuse the site-canonical 0.92 and 0.75 thresholds, an approver watching an imminent RFI reads the same signal vocabulary they see on every other routed record.
Frequently Asked Questions
Why must every timestamp be timezone-aware?
A contractual deadline is meaningless without a zone: 2026-07-15T09:00:00 is a different instant in Denver than in Boston, and subtracting a naive datetime from an aware one raises in Python anyway. Storing created_at and due_at as timezone-aware ISO 8601 instants makes the SLA math unambiguous across project sites and gives a claims review an exact, defensible submission and deadline.
How is a business-day deadline different from a calendar-day one?
A calendar-day clock adds the response days directly, so ten calendar days is always ten days later. A business-day clock advances only on working days, skipping weekends and configured holidays, so ten business days from a Wednesday can land two or more weeks out. The convention is a required contract field, never inferred, because the two can differ by several days and that difference decides a time extension.
Why pass an explicit `now` instead of calling datetime.now inside the classifier?
An explicit timezone-aware now makes breach classification a pure, reproducible function of its inputs. A test can assert the state at a fixed instant, and a claims reviewer can re-derive exactly the classification the automation made, rather than trusting a wall-clock read that changed every second the code ran.
How do the severity bands map to the site-canonical thresholds?
They reuse the same numbers used everywhere. The fraction of the SLA window consumed is scored: below 0.75 is on-track, 0.75 to 0.92 is on-watch and notifies the assignee, 0.92 and above is imminent and warns the approver, and any record where now passes due_at is breached and escalates. Only the breached state routes to the fallback alert router.
What makes a breach defensible for a time extension?
The three timezone-aware instants — created_at, due_at, and the now at which breach was detected — are logged together with the RFI number and the clock convention that produced the deadline. That record shows precisely when the response was contractually due, by which convention, and that it had not arrived, which is exactly the timestamped basis a time-extension claim rests on.
Related
- RFI Schema Design
- Schedule Impact Logging
- Fallback Alert Routing
- Submittal Metadata Frameworks
- Schema Validation Rules
← Back to RFI Schema Design