Skip to content

Linking RFI Response Delays to Schedule Slippage

A request for information sits open for three weeks past its answer-by date, and the activity it was blocking slips. Everyone on site knows the two are connected; proving it for a time-extension claim is another matter. This page solves one precise problem: how to correlate an RFI’s response-clock breach against its SLA to the downstream activity slippage it caused, so the delay has a documented, defensible basis rather than a hunch. The correlation has two halves — measure how late the answer actually was against the agreed response window, then join that breach to the specific activities that depended on the answer and lost float while it was outstanding. It belongs to schedule impact logging within schedule sync integration, and it consumes the timestamps defined by RFI schema design — submitted, due, and answered — to compute a latency the claim can stand on. This page targets Python automation builders and schedulers who need RFI-driven delay evidence tied to real activity dependencies.

RFI response-delay to activity-slippage correlation A left-to-right flow. An RFI record carrying submitted, due, and answered timestamps enters response-latency computation, which subtracts the due date from the answered date in business days to produce a breach in days. That breach reaches a dependency decision that asks whether an affected activity actually depended on the RFI answer. If yes, the breach joins to the activity's float loss and produces an attributed slippage record. If no, the path routes to an unattributed-slippage box held for review, so slippage is never blamed on an RFI without a dependency link. RFI record submitted · due answered Response latency answered − due business days Activity depended on answer? Yes Attributed slippage breach joined to float loss No Unattributed held for review
The breach is only half the story: slippage is attributed to the RFI only when an activity genuinely depended on the answer, otherwise it is held for review rather than blamed on the delay.

Key Rules and Specification

Correlating an RFI delay to slippage is a two-step contract, and each step has rules that keep the result defensible:

  • Measure latency against the SLA, not the calendar. The breach is answered_at − due_at, and the due date itself is derived from submitted_at plus the SLA. If the SLA is stated in business days, the arithmetic must skip weekends and holidays — a calendar-day count overstates the breach and hands the other side a reason to reject the whole figure.
  • Timestamps are timezone-aware. submitted_at, due_at, and answered_at are all timezone-aware per ISO 8601; a naive datetime is rejected because a latency computed across mixed zones is indefensible.
  • A breach is signed. A negative latency means the answer came early or on time — no breach, no slippage to attribute. Only a positive breach can be joined to a slip.
  • Attribution requires a dependency link. Slippage is joined to the breach only for activities that actually depended on the RFI answer. An activity that slipped for an unrelated reason must never be attributed to the RFI just because the dates overlap.
  • Float loss is integer days. The float an activity lost while the RFI was outstanding is an integer number of working days, matching how the schedule engine reports total float.
Field Type / rule Purpose
submitted_at / due_at / answered_at tz-aware datetime The RFI response clock
sla_business_days int, ge=1 Agreed response window
breach_days int, derived Business-day lateness of the answer
depends_on_rfi bool per activity Gates attribution
float_lost_days int, ge=0 Float the activity burned

Production Code Example

The code computes the response breach in business days from timezone-aware timestamps, then joins it to affected activities only where a dependency link exists. Money is out of scope here — the RFI impact is measured in days — but every timestamp is validated at the boundary.

from __future__ import annotations

import logging
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from uuid import UUID

from pydantic import BaseModel, Field, field_validator, model_validator

logger = logging.getLogger("schedule.impact.rfi")

WBS_PATTERN = r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$"


def business_days_between(start: datetime, end: datetime, holidays: set[date]) -> int:
    """Count whole business days from start to end, skipping weekends and holidays."""
    if end <= start:
        return -business_days_between(end, start, holidays) if end < start else 0
    days = 0
    cursor = start.date() + timedelta(days=1)
    last = end.date()
    while cursor <= last:
        if cursor.weekday() < 5 and cursor not in holidays:  # Mon–Fri, not a holiday
            days += 1
        cursor += timedelta(days=1)
    return days


class RFIResponse(BaseModel):
    """An RFI's response clock, used to measure an SLA breach."""

    rfi_id: str = Field(min_length=1, max_length=40)
    project_uuid: UUID
    submitted_at: datetime
    answered_at: datetime
    sla_business_days: int = Field(ge=1)

    @field_validator("submitted_at", "answered_at")
    @classmethod
    def tz_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("RFI timestamps must be timezone-aware (ISO 8601)")
        return v.astimezone(timezone.utc)

    @model_validator(mode="after")
    def answered_after_submitted(self) -> "RFIResponse":
        if self.answered_at < self.submitted_at:
            raise ValueError("answered_at cannot precede submitted_at")
        return self

    def breach_days(self, holidays: set[date]) -> int:
        """Business days the answer ran past its SLA. Negative means on time."""
        elapsed = business_days_between(self.submitted_at, self.answered_at, holidays)
        return elapsed - self.sla_business_days


class AffectedActivity(BaseModel):
    """A schedule activity and whether it genuinely depended on the RFI answer."""

    activity_id: str = Field(min_length=1, max_length=40)
    wbs_node: str = Field(pattern=WBS_PATTERN)
    depends_on_rfi: bool
    float_lost_days: int = Field(ge=0)


AttributionState = Literal["attributed", "unattributed"]


class RFISlippageLink(BaseModel):
    """The correlation between an RFI breach and one activity's float loss."""

    rfi_id: str
    activity_id: str
    wbs_node: str
    breach_days: int
    float_lost_days: int
    state: AttributionState


def link_rfi_to_slippage(
    rfi: RFIResponse,
    activities: list[AffectedActivity],
    holidays: set[date],
) -> list[RFISlippageLink]:
    """Join a positive SLA breach to activities that depended on the answer."""
    breach = rfi.breach_days(holidays)
    links: list[RFISlippageLink] = []
    for act in activities:
        # No breach, or no dependency, means no defensible attribution.
        attributed = breach > 0 and act.depends_on_rfi
        state: AttributionState = "attributed" if attributed else "unattributed"
        if not attributed:
            logger.info(
                "rfi.slippage.unattributed | rfi=%s activity=%s breach=%d dep=%s",
                rfi.rfi_id, act.activity_id, breach, act.depends_on_rfi,
            )
        links.append(
            RFISlippageLink(
                rfi_id=rfi.rfi_id, activity_id=act.activity_id, wbs_node=act.wbs_node,
                breach_days=max(breach, 0), float_lost_days=act.float_lost_days,
                state=state,
            )
        )
    return links


if __name__ == "__main__":
    holidays = {date(2026, 7, 3)}  # observed holiday inside the window
    rfi = RFIResponse(
        rfi_id="RFI-0117", project_uuid=UUID(int=14),
        submitted_at=datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc),
        answered_at=datetime(2026, 7, 13, 16, 0, tzinfo=timezone.utc),
        sla_business_days=5,
    )
    activities = [
        AffectedActivity(activity_id="A1440", wbs_node="PROJ-014-STR-02",
                         depends_on_rfi=True, float_lost_days=9),
        AffectedActivity(activity_id="A1500", wbs_node="PROJ-014-ARCH-01",
                         depends_on_rfi=False, float_lost_days=2),
    ]
    for link in link_rfi_to_slippage(rfi, activities, holidays):
        print(f"{link.activity_id}: breach={link.breach_days}d "
              f"float_lost={link.float_lost_days}d -> {link.state}")

The business_days_between helper is what keeps the breach honest: an answer that lands fifteen calendar days late may only be ten business days late once two weekends and a holiday are removed, and it is the business-day figure the SLA was written against.

Common Mistakes and Gotchas

Counting calendar days when the SLA is in business days. RFI SLAs are almost always stated in business days (“respond within five working days”), yet the naive (answered_at - due_at).days counts weekends and holidays. That inflates every breach, and once the opposing scheduler shows one overstated figure, the entire correlation is called into question. Compute the elapsed time in business days against the same calendar the SLA assumes, holidays included.

Timezone-naive timestamps. An RFI submitted in one site’s local time and answered in another, both stored naive, produces a latency that is wrong by the zone offset — and at a day boundary, wrong by a whole business day. The validator rejects naive datetimes so a mixed-zone latency never reaches the claim; attach each site’s zone at capture, per RFI schema design.

Attributing slippage with no dependency link. The most damaging shortcut is to blame every nearby slip on the late RFI because the dates line up. An activity that lost float for an unrelated reason — a subcontractor no-show, weather — must not be attributed to the RFI. Gate attribution on an explicit depends_on_rfi link so only activities that genuinely waited on the answer carry the RFI cause; everything else is held as unattributed for a scheduler to review.

Where This Fits in the Pipeline

This correlation is the RFI-delay path of schedule impact logging under schedule sync integration. It reads RFI timestamps modeled by RFI schema design and joins them to activity float loss reported by critical path delta detection. An attributed link becomes an impact record with cause rfi_delay, chained into the same tamper-evident trail that its sibling logging change order schedule impacts to an audit trail writes for approved change orders, so both delay sources share one auditable record. Unattributed slippage hands off to fallback alert routing for a scheduler to triage rather than being dropped.

Frequently Asked Questions

Should the SLA clock use business days or calendar days?

Whatever the contract or RFI procedure specifies — and it is almost always business days. The point is consistency: measure the breach against the same calendar the SLA was written against, holidays included. Mixing a business-day SLA with a calendar-day breach overstates the delay and undermines the whole correlation the moment it is reviewed.

Why gate attribution on an explicit dependency flag?

Because temporal overlap is not causation. Several activities can slip during the weeks an RFI is open for reasons that have nothing to do with the answer, and attributing all of them to the RFI inflates the claim and invites rejection. The depends_on_rfi link ensures only activities that actually waited on the answer carry the RFI cause; the rest are held as unattributed for review.

What does a negative breach mean?

The answer arrived early or exactly on time, so there is no SLA breach and nothing to attribute. The code clamps the stored breach_days at zero and marks every activity unattributed, because a schedule slip during that period cannot be blamed on a response that met its window.

Is the float loss the same as the breach?

No, and conflating them is a common error. The breach is how late the answer was against its SLA; the float loss is how much total float the dependent activity actually burned while blocked. They are usually correlated but rarely identical, because an activity may have started with slack that absorbed part of the delay. The record keeps both figures so the relationship is visible.

How does this become an entry in the audit trail?

An attributed link is converted into a schedule impact record with cause rfi_delay and the rfi_id as its source linkage, then chained into the append-only trail described in schedule impact logging. From that point it is tamper-evident evidence, indistinguishable in form from a change-order impact except for the cause and the id that proves it.

← Back to Schedule Impact Logging