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 expiredcheck 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.
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_remainingis exactly0, the approval expires today and is stillACTIVEunder this model (the calendar day has not yet fully elapsed); it becomesLAPSEDonly 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_onis after the localtoday, 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
IrbApprovalrecords; evaluate each independently and combine with a logical AND — the site is onlyACTIVEif every board’s approval is active, mirroring the fan-in gating in IRB/Ethics Workflow Mapping. - Daylight saving transitions near midnight. Because
_local_todayconverts throughzoneinforather 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_timezoneis empty or malformed,ZoneInforaisesZoneInfoNotFoundErrorimmediately — 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.