Tracking Protocol Amendments Across Central and Local IRBs

A protocol amendment does not take effect the moment a sponsor writes it, and it does not take effect everywhere at once. Under 45 CFR 46.109(a)(4) and 21 CFR 56.109(a), an IRB must review and approve a proposed change to approved research before the change is implemented, except where the change is necessary to eliminate an apparent immediate hazard to a subject. In a multi-board study, that rule applies independently at every reviewing board: the central board must approve the amendment, and each local board must separately approve it before its own sites may implement it. This page builds the tracker that keeps an amendment’s status honest across every board reviewing it, and it extends the coordination pattern in Automating Multi-Board IRB Submissions with a State Machine, part of IRB/Ethics Workflow Mapping within Core Architecture & Regulatory Mapping for Clinical Trials. The board-level transition mechanics reuse the engine described in How to Map IRB Submission Workflows to Automated State Machines; what an amendment adds is versioning, an effective date per board, and a per-site implementation gate.

Why naive approaches fail

An amendment is where multi-board tracking usually breaks down, because unlike the initial protocol submission, it arrives on top of an already-running study with sites at different stages of activation:

  • Overwriting the protocol’s status in place. If “amendment under review” simply replaces the study’s IRB status field, the record loses the fact that the original approval is still valid and the study can keep enrolling under the prior version while the amendment is pending — a distinction 45 CFR 46.109 depends on.
  • Treating the amendment as approved study-wide the moment one board signs off. A central board approving an amendment does not authorize a site whose own local board has not yet reviewed it to implement the change; that site must keep operating under the prior protocol version until its board acts.
  • No effective date, only an approval date. A board can approve an amendment with a future effective date, or with conditions that delay implementation. Recording only “approved” without a separate effective date makes it impossible to answer “as of today, which version is this site actually running?”
  • Losing the amendment’s version history. A study can accumulate several amendments over its life; if each new one overwrites the last recorded version rather than appending to a numbered sequence, there is no way to reconstruct which version was in force on a given date — exactly what continuing review and an inspection both ask for.

Architecture overview

An amendment is modeled once at the protocol level, then reviewed independently by every board already tracking that protocol. Each board’s review of the amendment produces its own state, its own effective date, and its own gate over the sites it governs.

Protocol amendment tracking pipeline Five stages left to right: the drafted amendment, central IRB review, local IRB review at each site, the effective date each board records on approval, and the per-site gate that opens only once that site's own board has approved. Draft Central IRB Local IRBs Effective Site gate

Setup and configuration

The tracker needs only the standard library: enum for states, dataclasses for immutable version and review records, datetime for effective dates, and logging for the audit trail. Configuration that governs policy — how many days before a continuing review deadline an amendment’s status should be flagged for follow-up — comes from the environment.

python -m venv .venv && source .venv/bin/activate
python -m pip install --upgrade pip
"""Environment-driven configuration for amendment tracking."""
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class AmendmentTrackerConfig:
    follow_up_window_days: int

    @classmethod
    def from_env(cls) -> "AmendmentTrackerConfig":
        raw = os.environ.get("IRB_AMENDMENT_FOLLOW_UP_DAYS", "14")
        try:
            days = int(raw)
        except ValueError as exc:
            raise RuntimeError(f"IRB_AMENDMENT_FOLLOW_UP_DAYS must be an integer, got {raw!r}") from exc
        if days <= 0:
            raise RuntimeError("IRB_AMENDMENT_FOLLOW_UP_DAYS must be a positive integer")
        return cls(follow_up_window_days=days)

Full working implementation

An AmendmentVersion is the immutable description of what changed and whether the change is substantive enough to require re-consent — that flag is what the companion re-consent workflow reads. A BoardAmendmentReview is one board’s independent progress reviewing that version. The AmendmentTracker ties them together per protocol and answers the one question site operations actually needs: is this site cleared to implement this version yet.

"""Track a protocol amendment through every reviewing IRB."""
from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from enum import Enum

logger = logging.getLogger("irb_amendment_tracker")


class AmendmentReviewState(str, Enum):
    DRAFTED = "DRAFTED"
    SUBMITTED = "SUBMITTED"
    UNDER_REVIEW = "UNDER_REVIEW"
    REVISIONS_REQUESTED = "REVISIONS_REQUESTED"
    APPROVED = "APPROVED"
    NOT_APPROVED = "NOT_APPROVED"


AMENDMENT_TRANSITIONS: dict[AmendmentReviewState, frozenset[AmendmentReviewState]] = {
    AmendmentReviewState.DRAFTED: frozenset({AmendmentReviewState.SUBMITTED}),
    AmendmentReviewState.SUBMITTED: frozenset({AmendmentReviewState.UNDER_REVIEW}),
    AmendmentReviewState.UNDER_REVIEW: frozenset(
        {AmendmentReviewState.REVISIONS_REQUESTED, AmendmentReviewState.APPROVED, AmendmentReviewState.NOT_APPROVED}
    ),
    AmendmentReviewState.REVISIONS_REQUESTED: frozenset({AmendmentReviewState.UNDER_REVIEW}),
    AmendmentReviewState.APPROVED: frozenset(),      # terminal per board
    AmendmentReviewState.NOT_APPROVED: frozenset(),  # terminal per board
}


class IllegalAmendmentTransitionError(RuntimeError):
    """Raised when a board amendment review attempts a move outside AMENDMENT_TRANSITIONS."""


@dataclass(frozen=True)
class AmendmentVersion:
    """One numbered, immutable version of a protocol amendment."""

    amendment_id: str
    protocol_id: str
    version_number: int
    summary: str
    is_substantive: bool  # drives whether enrolled participants must re-consent
    drafted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


@dataclass(frozen=True)
class AmendmentReviewEvent:
    amendment_id: str
    board_id: str
    state_from: AmendmentReviewState
    state_to: AmendmentReviewState
    actor: str
    reason: str
    timestamp: datetime


@dataclass
class BoardAmendmentReview:
    """One board's independent review of a specific amendment version."""

    amendment_id: str
    board_id: str
    site_id: str | None  # None for the central board
    state: AmendmentReviewState = AmendmentReviewState.DRAFTED
    effective_date: date | None = None
    history: list[AmendmentReviewEvent] = field(default_factory=list)

    def transition(
        self,
        target: AmendmentReviewState,
        actor: str,
        reason: str,
        effective_date: date | None = None,
    ) -> AmendmentReviewEvent:
        if not actor:
            raise ValueError("actor is required for an attributable audit trail")
        if target not in AMENDMENT_TRANSITIONS[self.state]:
            allowed = sorted(s.value for s in AMENDMENT_TRANSITIONS[self.state])
            raise IllegalAmendmentTransitionError(
                f"{self.board_id}/{self.amendment_id}: {self.state.value} -> {target.value} "
                f"not permitted; allowed: {allowed}"
            )
        if target is AmendmentReviewState.APPROVED and effective_date is None:
            raise ValueError("an APPROVED amendment review must record an effective_date")
        event = AmendmentReviewEvent(
            amendment_id=self.amendment_id,
            board_id=self.board_id,
            state_from=self.state,
            state_to=target,
            actor=actor,
            reason=reason,
            timestamp=datetime.now(timezone.utc),
        )
        self.state = target
        if effective_date is not None:
            self.effective_date = effective_date
        self.history.append(event)
        logger.info(json.dumps({
            "action": "amendment_board_transition",
            "amendment_id": self.amendment_id,
            "board_id": self.board_id,
            "site_id": self.site_id,
            "from": event.state_from.value,
            "to": event.state_to.value,
            "effective_date": effective_date.isoformat() if effective_date else None,
            "actor": actor,
        }, sort_keys=True))
        return event


@dataclass
class AmendmentTracker:
    """Tracks one AmendmentVersion across every board reviewing the protocol."""

    version: AmendmentVersion
    board_reviews: dict[str, BoardAmendmentReview] = field(default_factory=dict)

    def register_board(self, board_id: str, site_id: str | None) -> BoardAmendmentReview:
        if board_id in self.board_reviews:
            raise ValueError(f"board {board_id!r} already tracking amendment {self.version.amendment_id!r}")
        review = BoardAmendmentReview(amendment_id=self.version.amendment_id, board_id=board_id, site_id=site_id)
        self.board_reviews[board_id] = review
        return review

    def site_may_implement(self, board_id: str, as_of: date) -> bool:
        """A site may implement this version once its board approved and the effective date has arrived."""
        review = self.board_reviews.get(board_id)
        if review is None:
            return False
        return (
            review.state is AmendmentReviewState.APPROVED
            and review.effective_date is not None
            and review.effective_date <= as_of
        )

    def sites_blocked(self, as_of: date) -> list[str]:
        """Sites still operating under the prior version because their board has not cleared this one."""
        return sorted(
            review.site_id
            for review in self.board_reviews.values()
            if review.site_id is not None and not self.site_may_implement(review.board_id, as_of)
        )

The effective_date guard on the transition into APPROVED is deliberate: a board can approve an amendment while deferring its implementation, and site_may_implement treats that combination correctly — approved, but not yet in force — because it checks both the state and the date rather than the state alone.

Validation and edge-case handling

Three situations recur across studies that carry more than one open amendment:

  • A board approves with a future effective date. site_may_implement correctly reports False until as_of reaches the recorded effective_date, so a coordinator cannot accidentally roll out a change at a site before the board’s own effective date, even though the review record already shows APPROVED.
  • Two amendments open at once. Each AmendmentVersion gets its own AmendmentTracker keyed by amendment_id; nothing here assumes a protocol has only one amendment in flight. A site’s true current version is the highest-numbered amendment for which site_may_implement returns true at that site’s board, which is a query over the tracker instances rather than a single flag.
  • A board requests revisions to the amendment language itself. The REVISIONS_REQUESTED state loops back to UNDER_REVIEW, mirroring the same cycle used for the initial protocol review, and every cycle is preserved in history rather than overwritten — the same discipline the underlying engine applies to the original submission.

Because a BoardAmendmentReview can only reach APPROVED through a guarded transition call that requires both an actor and an effective date, there is no code path that lets an amendment become implementable without an attributable, dated board decision behind it.

Testing and verification

The tests worth writing here focus on the two properties operations teams actually rely on: a site stays blocked until its own board clears the amendment, and an approval without a future-dated effective date does not unlock implementation early.

"""Tests for cross-board amendment tracking."""
from __future__ import annotations

from datetime import date

import pytest


def _tracker() -> AmendmentTracker:
    version = AmendmentVersion(
        amendment_id="AMD-003", protocol_id="STUDY-2026-004",
        version_number=3, summary="Add optional pharmacokinetic sub-study", is_substantive=True,
    )
    tracker = AmendmentTracker(version=version)
    tracker.register_board("central", site_id=None)
    tracker.register_board("site-014", site_id="014")
    tracker.register_board("site-022", site_id="022")
    return tracker


def _approve(tracker: AmendmentTracker, board_id: str, effective: date) -> None:
    review = tracker.board_reviews[board_id]
    review.transition(AmendmentReviewState.SUBMITTED, actor="coordinator", reason="submitted to board")
    review.transition(AmendmentReviewState.UNDER_REVIEW, actor="board_admin", reason="intake accepted")
    review.transition(AmendmentReviewState.APPROVED, actor="board_chair", reason="approved", effective_date=effective)


def test_site_stays_blocked_until_its_own_board_approves() -> None:
    tracker = _tracker()
    _approve(tracker, "central", effective=date(2026, 8, 1))
    _approve(tracker, "site-014", effective=date(2026, 8, 1))
    assert tracker.sites_blocked(as_of=date(2026, 8, 1)) == ["022"]
    _approve(tracker, "site-022", effective=date(2026, 8, 15))
    assert tracker.sites_blocked(as_of=date(2026, 8, 1)) == ["022"]
    assert tracker.sites_blocked(as_of=date(2026, 8, 15)) == []


def test_approval_without_effective_date_is_rejected() -> None:
    tracker = _tracker()
    review = tracker.board_reviews["site-014"]
    review.transition(AmendmentReviewState.SUBMITTED, actor="coordinator", reason="submitted")
    review.transition(AmendmentReviewState.UNDER_REVIEW, actor="board_admin", reason="accepted")
    with pytest.raises(ValueError):
        review.transition(AmendmentReviewState.APPROVED, actor="board_chair", reason="approved")

The first test is the guarantee an inspector will probe directly: two sites can carry the same amendment through their local boards on entirely different timelines, and neither the earlier approval nor an early as_of check ever lets the slower site implement a version its own board has not yet cleared. The second confirms the tracker cannot silently drop the effective date on the floor.