Scheduling Expedited Safety Report Deadlines in Python

An expedited safety report deadline is set by a single fact — the date a sponsor first held enough information to recognize a suspected unexpected serious adverse reaction — and by a single rule: 7 calendar days if the event was fatal or life-threatening, 15 calendar days for any other serious, unexpected reaction. Getting either the fact or the rule wrong produces a deadline that looks correct in the code and is wrong in an inspection. This walkthrough builds the calculator itself: parsing the awareness date correctly, applying the calendar-day clock in the right timezone, and emitting a due date and reminder schedule an inspector can trust. It is the concrete implementation behind Submission Deadline & Scheduling Automation, part of the Core Architecture & Regulatory Mapping for Clinical Trials domain, and every computed deadline is written to the append-only audit log so it can be reconstructed from the awareness date alone.

Why naive approaches fail

A first attempt at this calculator usually looks like due_date = aware_date + timedelta(days=7) run on whatever datetime object happened to be lying around. Several failure modes hide inside that one line:

  • Naive datetimes with no timezone. A datetime without a tzinfo has no defensible meaning once a day boundary is involved — “day 7” depends entirely on which timezone’s midnight you mean, and a naive object cannot answer that question.
  • Arithmetic across a daylight-saving transition. Adding timedelta(days=7) to a timezone-aware value that uses a fixed UTC offset (rather than a real IANA zone) silently produces a wall-clock time an hour off whenever the window crosses a spring-forward or fall-back boundary — invisible until someone checks the due date against a wall clock in that zone.
  • Treating the deadline as business days. There is no weekend or holiday extension for expedited safety reporting; a calculator that reuses a general-purpose “add N business days” helper built for other filings will compute a due date that is too late, and too late is a compliance failure, not a rounding error.
  • Losing the original awareness timestamp. If only the computed due date is persisted and the awareness timestamp that produced it is discarded or overwritten, the deadline cannot be independently re-derived later — which is exactly what an inspector will ask you to do.

Architecture overview

The calculator is a short, pure pipeline: parse the awareness timestamp with its timezone, classify severity, add the calendar-day window, localize the result to the controlling timezone, and emit the due date alongside an audit record.

Expedited safety report deadline pipeline Five stages left to right: parse the awareness date, classify severity, add the calendar-day window, localize to the controlling timezone, and emit the due date with an audit record. Each stage feeds the next with a single arrow. Parse date Classify tier Add window Localize TZ Emit + audit

Setup and configuration

The zoneinfo module is standard library since Python 3.9, so no third-party timezone package is required. On some minimal Linux base images the IANA database itself is not installed system-wide, so add tzdata as a safety net.

pip install "tzdata>=2024.1"

Configuration — the authority’s controlling timezone and the two window lengths — is read from the environment so the same calculator serves multiple authorities without a code change:

"""Environment-driven configuration for the deadline calculator."""
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class DeadlineConfig:
    authority_timezone: str    # IANA key, e.g. "America/New_York"
    fatal_window_days: int
    other_serious_window_days: int

    @classmethod
    def from_env(cls) -> "DeadlineConfig":
        def required(name: str) -> str:
            value = os.environ.get(name)
            if not value:
                raise RuntimeError(f"Missing required environment variable: {name}")
            return value

        return cls(
            authority_timezone=required("SAFETY_REPORT_AUTHORITY_TZ"),
            fatal_window_days=int(os.environ.get("SAFETY_REPORT_FATAL_DAYS", "7")),
            other_serious_window_days=int(os.environ.get("SAFETY_REPORT_OTHER_DAYS", "15")),
        )

Defaulting the window lengths to 7 and 15 documents the ICH E2A baseline directly in code while still letting a stricter internal policy override it per environment.

Full working implementation

The calculator is deliberately small: one function to parse a timezone-aware awareness timestamp, one to classify severity into a window, one to compute the due date, and one to package the result with an audit record.

"""Compute an expedited safety report deadline from an awareness timestamp."""
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from enum import Enum
from zoneinfo import ZoneInfo


class SeverityTier(str, Enum):
    FATAL_OR_LIFE_THREATENING = "fatal_or_life_threatening"
    OTHER_SERIOUS = "other_serious"


class DeadlineError(ValueError):
    """Raised when an awareness timestamp cannot be safely used to compute a deadline."""


@dataclass(frozen=True)
class SafetyReportDeadline:
    subject_id: str
    severity: SeverityTier
    aware_at: datetime
    due_at: datetime
    window_days: int
    audit_hash: str


def parse_awareness_timestamp(raw: str, reporter_timezone: str) -> datetime:
    """Parse a naive local timestamp and attach the site's own IANA zone.

    Raises DeadlineError if the zone key is unknown, rather than silently
    falling back to UTC.
    """
    try:
        tz = ZoneInfo(reporter_timezone)
    except Exception as exc:
        raise DeadlineError(f"unknown timezone key: {reporter_timezone!r}") from exc
    try:
        naive = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
    except ValueError as exc:
        raise DeadlineError(f"unparseable awareness timestamp: {raw!r}") from exc
    return naive.replace(tzinfo=tz)


def window_for(severity: SeverityTier, config: "DeadlineConfig") -> int:
    if severity is SeverityTier.FATAL_OR_LIFE_THREATENING:
        return config.fatal_window_days
    return config.other_serious_window_days


def compute_deadline(
    subject_id: str,
    severity: SeverityTier,
    aware_at: datetime,
    config: "DeadlineConfig",
) -> SafetyReportDeadline:
    if aware_at.tzinfo is None:
        raise DeadlineError("aware_at must be timezone-aware")

    authority_tz = ZoneInfo(config.authority_timezone)
    aware_in_authority_tz = aware_at.astimezone(authority_tz)
    window_days = window_for(severity, config)

    due_date: date = (aware_in_authority_tz + timedelta(days=window_days)).date()
    due_at = datetime.combine(due_date, time(23, 59, 59), tzinfo=authority_tz)

    record = {
        "subject_id": subject_id,
        "severity": severity.value,
        "aware_at": aware_at.isoformat(),
        "due_at": due_at.isoformat(),
        "window_days": window_days,
    }
    serialized = json.dumps(record, sort_keys=True, separators=(",", ":"))
    audit_hash = hashlib.sha256(serialized.encode()).hexdigest()

    return SafetyReportDeadline(
        subject_id=subject_id,
        severity=severity,
        aware_at=aware_at,
        due_at=due_at,
        window_days=window_days,
        audit_hash=audit_hash,
    )

aware_in_authority_tz performs the timezone conversion before any day arithmetic runs, so the day boundary that matters is always the authority’s, not whichever zone the awareness event happened to be recorded in. The audit_hash binds the subject, severity, both timestamps, and the window length into one value, so a later dispute over “what was the deadline computed from” resolves to a hash comparison rather than a debate.

A small helper turns the result into reminder checkpoints, proportionally spaced so a 7-day and a 15-day window both leave meaningful lead time before the final escalation:

"""Derive reminder checkpoints from a computed deadline."""
from __future__ import annotations


def reminder_checkpoints(deadline: SafetyReportDeadline) -> tuple[datetime, ...]:
    span = deadline.due_at - deadline.aware_at
    return tuple(
        deadline.aware_at + span * fraction
        for fraction in (0.4, 0.7, 0.9)
    )


def escalation_checkpoint(deadline: SafetyReportDeadline) -> datetime:
    return deadline.due_at - timedelta(hours=24)

Validation and edge-case handling

Three edge cases account for most of the disputes that surface when a computed deadline is challenged after the fact:

  • Awareness recorded exactly at a DST transition. Because the conversion goes through astimezone() on a real ZoneInfo object rather than a fixed offset, an awareness timestamp recorded during the one-hour window that repeats or is skipped at a DST boundary still resolves to a single, correct instant — zoneinfo disambiguates using the fold attribute internally, which a fixed-offset or pytz-based implementation cannot do reliably.
  • An awareness date near a month or year boundary. date + timedelta(days=N) correctly rolls across month and year boundaries because it operates on the proleptic Gregorian calendar, so a report becoming aware on December 27 with a 7-day window correctly resolves into the following January without special-casing.
  • A subject whose site timezone differs from the authority’s timezone by a large offset. Converting to the authority’s zone before computing the date means a report that becomes aware late in the evening site-local time can land on a different authority-local calendar date than a naive read of the raw string would suggest — which is exactly the intended behavior, since the controlling clock is the authority’s, not the site’s.

Testing and verification

The test suite pins down the two properties that matter most: the calendar-day math is exactly 7 or 15 days regardless of weekends, and a DST transition inside the window does not shift the computed due date by an hour.

"""Tests for the expedited safety report deadline calculator."""
from __future__ import annotations

from datetime import datetime
from zoneinfo import ZoneInfo

import pytest


@pytest.fixture
def config() -> DeadlineConfig:
    return DeadlineConfig(
        authority_timezone="America/New_York",
        fatal_window_days=7,
        other_serious_window_days=15,
    )


def test_fatal_window_is_seven_calendar_days(config: DeadlineConfig) -> None:
    aware_at = datetime(2026, 7, 10, 9, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles"))
    deadline = compute_deadline("SUBJ-001", SeverityTier.FATAL_OR_LIFE_THREATENING, aware_at, config)
    assert deadline.due_at.date().isoformat() == "2026-07-17"


def test_other_serious_window_spans_a_weekend_without_extension(config: DeadlineConfig) -> None:
    # July 10, 2026 is a Friday; the 15-day window crosses two weekends
    # and must not be extended for either of them.
    aware_at = datetime(2026, 7, 10, 9, 0, 0, tzinfo=ZoneInfo("America/New_York"))
    deadline = compute_deadline("SUBJ-002", SeverityTier.OTHER_SERIOUS, aware_at, config)
    assert deadline.due_at.date().isoformat() == "2026-07-25"


def test_dst_transition_inside_window_does_not_shift_due_date(config: DeadlineConfig) -> None:
    # October 28, 2026 is still Eastern Daylight Time; the 15-day window
    # crosses the November 1 fall-back transition into Eastern Standard
    # Time, and the computed calendar date must still land exactly on day 15.
    aware_at = datetime(2026, 10, 28, 20, 0, 0, tzinfo=ZoneInfo("America/New_York"))
    deadline = compute_deadline("SUBJ-003", SeverityTier.OTHER_SERIOUS, aware_at, config)
    assert deadline.due_at.date().isoformat() == "2026-11-12"


def test_naive_awareness_timestamp_is_rejected(config: DeadlineConfig) -> None:
    naive = datetime(2026, 7, 10, 9, 0, 0)
    with pytest.raises(DeadlineError):
        compute_deadline("SUBJ-004", SeverityTier.FATAL_OR_LIFE_THREATENING, naive, config)

The DST test is the one worth defending in code review even though it looks unremarkable: it is a regression guard against exactly the class of bug a fixed-offset or pytz-based implementation would introduce silently, and it is cheap to keep once it exists.

FAQ

Why end-of-day rather than the same time of day N days later?

Ending the window at 23:59:59 in the authority’s timezone is the conservative reading of within N calendar days — it gives the full Nth day rather than cutting it off at whatever hour the awareness event happened to occur. A sponsor who submits at any point during day N, however late, is within the window; treating the deadline as the same wall-clock time N days later would incorrectly narrow it.

Does this calculator ever need business-day logic?

No. Expedited safety report deadlines run in calendar days with no weekend or holiday extension. Business-day adjustment belongs only to periodic, administrative filings, and mixing that logic into this calculator risks quietly extending a safety deadline it has no authority to extend.

What happens if the awareness timestamp is corrected after the deadline is already computed?

Treat the correction as a new awareness event and compute a new, distinct deadline linked back to the original rather than editing the existing record in place. Both the original and corrected deadlines should be retained in the audit log so the full sequence of what was known, and when, remains reconstructable.