Flagging Expired IRB Approval Before Site Activation

An Institutional Review Board approval is not a permanent artifact. It carries an expiry date, and under 45 CFR 46.109 and 21 CFR 56.109 continuing review must occur before that date passes or the approval lapses — enrolling, or continuing to enroll, subjects under a lapsed approval is a reportable deviation. This walkthrough builds the detector that parses an approval’s dates, applies a continuing-review buffer, and raises an activation-blocking finding before a site goes live on ethics oversight that is expired or about to be. It is one of the concrete activation blockers surveyed by Checklist Sync & Gap Analysis inside Automated Document Ingestion & Validation Workflows, and it reads its ethics state from the same lifecycle modeled in IRB/Ethics Workflow Mapping. Two neighboring detectors close out the same activation gate: a missing delegation of authority log and a stale SOP acknowledgement.

Why naive approaches fail

Treating IRB expiry as “check whether expires_on is before today” is the obvious first implementation, and it fails in several ways that only surface once a program runs across time zones and continuing-review cycles:

  • Wall-clock date comparisons drift across regions. A coordinator running the activation check from a US-based server at 11 PM Eastern can compute “today” as a date that is already tomorrow at a site in Singapore. An approval expiring on that boundary date can look valid from the server’s perspective and lapsed from the site’s, or vice versa.
  • No warning before the cliff. A pure expired / not expired check gives no lead time. By the time the approval has actually lapsed, there is no way to avoid an enrollment gap — continuing review has to be filed and approved before the expiry date, so the detector needs to raise the flag while there is still time to act.
  • Continuing review status is ignored. An approval nearing its expiry with a continuing-review submission already in the IRB’s queue is a very different risk than one with no submission at all. Collapsing both into a single “near expiry” bucket loses the information a coordinator needs to know whether to escalate or just wait.
  • Naive timestamps hide which clock was used. Recording “checked at 2026-07-17” without a timezone, or using a naive datetime, makes the record impossible to defend later — an inspector reconstructing the sequence of events cannot tell whether the check ran against UTC, the sponsor’s timezone, or the site’s.

The fix is to make the evaluation date an explicit, timezone-aware input rather than an implicit call to the wall clock, and to model continuing-review buffer, submission status, and expiry as three distinct signals rather than one boolean.

Architecture overview

The detector parses the stored approval and expiry dates, resolves “today” in the site’s own timezone, applies the sponsor’s continuing-review buffer, and classifies the result into one of four states before emitting an audit record.

IRB approval expiry check pipeline Five stages left to right: approval record, site-local evaluation date, buffer comparison, expiry classification, and audit record with activation hold. Approval Local date Buffer check Classify Audit + hold

A lapsed or near-expiry finding here is one of the hard gates that the weighted model in Clinical Site Readiness Assessment Frameworks treats as non-negotiable: no amount of otherwise-complete documentation raises a site’s readiness score above zero while its ethics approval is lapsed.

Setup and configuration

The detector needs only the standard library’s zoneinfo for timezone-correct date resolution and pytest for the test suite.

pip install "pytest>=8.0" "tzdata; platform_system == 'Windows'"

The continuing-review buffer and the site’s IANA timezone are policy and site metadata respectively, so both are read from configuration rather than hardcoded:

"""Environment-driven configuration for IRB expiry checks."""
from __future__ import annotations

import os
from dataclasses import dataclass


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


@dataclass(frozen=True)
class IrbExpiryConfig:
    continuing_review_buffer_days: int
    default_timezone: str

    @classmethod
    def from_env(cls) -> "IrbExpiryConfig":
        return cls(
            continuing_review_buffer_days=int(
                os.environ.get("IRB_CONTINUING_REVIEW_BUFFER_DAYS", "45")
            ),
            default_timezone=_required_env("IRB_DEFAULT_TIMEZONE"),
        )

A 45-day default buffer gives regulatory affairs time to react to a NEAR_EXPIRY finding before it becomes a lapsed approval; sponsors running higher-risk studies commonly widen this in configuration rather than in code.

Full working implementation

Model the approval record, the evaluation context, and the finding as immutable structures, and resolve “today” against the site’s own timezone using zoneinfo rather than the process’s local clock.

"""Detect a lapsed or near-expiry IRB approval before site activation."""
from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime, timezone
from enum import Enum
from zoneinfo import ZoneInfo


class ApprovalStatus(str, Enum):
    """Classification of an IRB approval as of the evaluation date."""

    ACTIVE = "active"
    NEAR_EXPIRY = "near_expiry"
    LAPSED = "lapsed"
    MISSING = "missing"


class Severity(str, Enum):
    CRITICAL = "P0"
    HIGH = "P1"
    OK = "ok"


@dataclass(frozen=True, slots=True)
class IrbApproval:
    """One IRB/IEC approval record for a site's protocol."""

    site_id: str
    protocol_id: str
    approved_on: date
    expires_on: date
    continuing_review_submitted_on: date | None
    site_timezone: str  # IANA zone, e.g. "America/New_York"


@dataclass(frozen=True, slots=True)
class IrbExpiryFinding:
    """Result of evaluating one approval's expiry state."""

    site_id: str
    protocol_id: str
    status: ApprovalStatus
    severity: Severity
    days_remaining: int | None
    reason: str
    blocks_activation: bool


def _local_today(evaluated_at_utc: datetime, tz_name: str) -> date:
    """Resolve the calendar date at the site's location, not the server's.

    Approval expiry is defined on a calendar date in the site's own
    timezone; comparing it against a UTC-derived date can be off by a
    day in either direction near midnight boundaries.
    """
    if evaluated_at_utc.tzinfo is None:
        raise ValueError("evaluated_at_utc must be timezone-aware")
    local = evaluated_at_utc.astimezone(ZoneInfo(tz_name))
    return local.date()


def evaluate_irb_expiry(
    approval: IrbApproval | None,
    site_id: str,
    protocol_id: str,
    evaluated_at_utc: datetime,
    buffer_days: int,
) -> IrbExpiryFinding:
    """Classify one IRB approval's expiry state as of an explicit instant.

    ``evaluated_at_utc`` must be timezone-aware; the function converts it
    to the site's local date before comparing against ``expires_on`` so
    the result matches the calendar day the site actually experiences.
    """
    if approval is None:
        return IrbExpiryFinding(
            site_id, protocol_id, ApprovalStatus.MISSING, Severity.CRITICAL,
            None, "no IRB approval record found for this site and protocol",
            blocks_activation=True,
        )

    today = _local_today(evaluated_at_utc, approval.site_timezone)
    days_remaining = (approval.expires_on - today).days

    if days_remaining < 0:
        return IrbExpiryFinding(
            site_id, protocol_id, ApprovalStatus.LAPSED, Severity.CRITICAL,
            days_remaining,
            f"approval expired on {approval.expires_on.isoformat()}, "
            f"{-days_remaining} days ago",
            blocks_activation=True,
        )

    if days_remaining <= buffer_days:
        has_submission = (
            approval.continuing_review_submitted_on is not None
            and approval.continuing_review_submitted_on <= today
        )
        reason = (
            f"expires in {days_remaining} days; continuing review "
            + ("already submitted" if has_submission else "not yet submitted")
        )
        return IrbExpiryFinding(
            site_id, protocol_id, ApprovalStatus.NEAR_EXPIRY,
            Severity.HIGH if has_submission else Severity.CRITICAL,
            days_remaining, reason,
            blocks_activation=not has_submission,
        )

    return IrbExpiryFinding(
        site_id, protocol_id, ApprovalStatus.ACTIVE, Severity.OK,
        days_remaining, f"expires in {days_remaining} days", blocks_activation=False,
    )

The near-expiry branch is the one worth reading twice: a site inside the buffer window with no continuing-review submission on file is still a critical, activation-blocking finding, because there is no evidence the lapse will be avoided. A site inside the same window with a submission already filed is downgraded to high severity — worth watching, not worth holding activation over, since the IRB’s own process is already in motion.

Emit an audit event for every evaluation, whether or not it produces a blocking finding, so the history of a site’s ethics-approval status is reconstructable from the append-only audit log alone:

"""Emit an audit event for a completed IRB expiry check."""
from __future__ import annotations

import json
import logging

logger = logging.getLogger("checklist_sync.irb_expiry_check")


def record_irb_expiry_check(finding: IrbExpiryFinding, principal: str) -> None:
    event = {
        "action": "irb_expiry_check",
        "site_id": finding.site_id,
        "protocol_id": finding.protocol_id,
        "status": finding.status.value,
        "severity": finding.severity.value,
        "days_remaining": finding.days_remaining,
        "activation_blocked": finding.blocks_activation,
        "checked_at": datetime.now(timezone.utc).isoformat(),
        "principal": principal,
    }
    logger.info(json.dumps(event, sort_keys=True))

Validation and edge-case handling

A handful of shapes account for most of the disputes a program has with its own IRB-expiry tooling:

  • Boundary day equality. When days_remaining is exactly 0, the approval expires today and is still ACTIVE under this model (the calendar day has not yet fully elapsed); it becomes LAPSED only the day after. Confirm this matches your sponsor SOP — some programs treat the expiry date itself as already lapsed, which is a one-line change to the comparison.
  • A continuing-review submission dated in the future. If continuing_review_submitted_on is after the local today, treat it as not-yet-submitted rather than submitted; a future-dated submission record is a data-entry error, not evidence of a filed review.
  • Multiple IRBs on one site. A site reviewed by both a central and a local board has two IrbApproval records; evaluate each independently and combine with a logical AND — the site is only ACTIVE if every board’s approval is active, mirroring the fan-in gating in IRB/Ethics Workflow Mapping.
  • Daylight saving transitions near midnight. Because _local_today converts through zoneinfo rather than applying a fixed UTC offset, a site observing daylight saving time resolves its local calendar date correctly even on the day of the transition; a naive fixed-offset subtraction would not.
  • Missing timezone metadata. If a site’s IrbApproval.site_timezone is empty or malformed, ZoneInfo raises ZoneInfoNotFoundError immediately — let it propagate rather than silently defaulting to UTC, since a wrong default is exactly the class of error this detector exists to prevent.

Testing and verification

The properties worth pinning are the timezone-correct boundary, the buffer classification, and the continuing-review downgrade, all of which are pure and need no network access to test.

"""Tests for IRB approval expiry detection."""
from __future__ import annotations

from datetime import date, datetime, timezone


def _approval(**over: object) -> IrbApproval:
    base = dict(
        site_id="SITE-014", protocol_id="PROTO-9",
        approved_on=date(2025, 7, 1), expires_on=date(2026, 7, 20),
        continuing_review_submitted_on=None, site_timezone="America/New_York",
    )
    base.update(over)
    return IrbApproval(**base)


def test_active_when_outside_buffer() -> None:
    finding = evaluate_irb_expiry(
        _approval(), "SITE-014", "PROTO-9",
        datetime(2026, 5, 1, tzinfo=timezone.utc), buffer_days=45,
    )
    assert finding.status is ApprovalStatus.ACTIVE
    assert finding.blocks_activation is False


def test_near_expiry_without_submission_blocks() -> None:
    finding = evaluate_irb_expiry(
        _approval(), "SITE-014", "PROTO-9",
        datetime(2026, 7, 1, tzinfo=timezone.utc), buffer_days=45,
    )
    assert finding.status is ApprovalStatus.NEAR_EXPIRY
    assert finding.blocks_activation is True


def test_near_expiry_with_submission_does_not_block() -> None:
    approval = _approval(continuing_review_submitted_on=date(2026, 6, 15))
    finding = evaluate_irb_expiry(
        approval, "SITE-014", "PROTO-9",
        datetime(2026, 7, 1, tzinfo=timezone.utc), buffer_days=45,
    )
    assert finding.status is ApprovalStatus.NEAR_EXPIRY
    assert finding.blocks_activation is False


def test_lapsed_after_expiry_date() -> None:
    finding = evaluate_irb_expiry(
        _approval(), "SITE-014", "PROTO-9",
        datetime(2026, 8, 1, tzinfo=timezone.utc), buffer_days=45,
    )
    assert finding.status is ApprovalStatus.LAPSED
    assert finding.blocks_activation is True


def test_missing_approval_is_critical() -> None:
    finding = evaluate_irb_expiry(
        None, "SITE-014", "PROTO-9",
        datetime(2026, 7, 1, tzinfo=timezone.utc), buffer_days=45,
    )
    assert finding.status is ApprovalStatus.MISSING
    assert finding.blocks_activation is True


def test_naive_datetime_is_rejected() -> None:
    import pytest
    with pytest.raises(ValueError):
        evaluate_irb_expiry(
            _approval(), "SITE-014", "PROTO-9",
            datetime(2026, 7, 1), buffer_days=45,  # tz-naive
        )

Run with pytest -q. The pair of near-expiry tests is the one to keep under regression watch: it is the difference between “escalate now” and “hold activation,” and it is exactly the distinction a coordinator needs when triaging a queue of dozens of sites approaching renewal at once.

FAQ

Why convert to the site’s local date instead of just using UTC everywhere?

IRB expiry is defined on a calendar date at the site’s physical location, and continuing review is a local regulatory obligation, not a UTC one. Evaluating a UTC-derived date against a locally defined expiry date can misclassify a site near a midnight boundary — treating it as expired a day early, or as active a day after it has actually lapsed at the site.

Does a continuing-review submission fully resolve a near-expiry finding?

No — it downgrades the severity from a hard activation block to a high-priority watch item, because the review is in the IRB’s queue but has not yet produced an approval. The finding only clears once the board issues a new approval with a later expiry date; the detector should be re-run once that happens rather than assumed resolved on submission alone.

How does this interact with central versus local IRB review?

A site with both a central and a local board needs both approvals evaluated independently, and the site is only clear when both return ACTIVE. This mirrors the fan-in gating in the ethics workflow state machine, where a central approval never substitutes for a missing local one.