IRB/Ethics Workflow Mapping

Modeling IRB and IEC ethics review as an explicit state machine turns site activation from an email-driven guessing game into a deterministic, auditable pipeline. This page maps the full review lifecycle, contrasts central and local IRB processes, aligns each stage with ICH E6(R2)/GCP, and shows how to encode legal transitions in Python so invalid moves are impossible.

Institutional Review Board (IRB) and Independent Ethics Committee (IEC) approval is the gate every study site must clear before a single participant can be screened. Yet most ethics submissions are tracked in spreadsheets and inboxes, where the “state” of a packet — submitted, in review, revisions requested, approved, under continuing review — lives only in someone’s head. That ambiguity is the root cause of stalled activations, expired approvals, and audit findings. Treating the review process as a finite-state machine (FSM) makes the current state explicit, makes every transition a recorded event, and makes illegal sequences (approving a packet that was never reviewed) structurally impossible.

This cluster sits within the Core Architecture & Regulatory Mapping for Clinical Trials pillar, which establishes the single-source-of-truth principles that an ethics workflow engine depends on. For the concrete implementation walk-through, see the child long-tail How to map IRB submission workflows to automated state machines.

The IRB/IEC Review Lifecycle as Discrete States

An ethics review is not a single approval event — it is a sequence of well-defined stages, each with its own actor, entry conditions, and required artifacts. ICH E6(R2) and FDA 21 CFR Part 56 describe these stages in regulatory language; an FSM encodes them in software. The canonical states are:

State Meaning Owning actor
DRAFT Packet being assembled at the site/sponsor Study team
SUBMITTED Packet delivered to the IRB/IEC intake Sponsor / coordinator
ADMIN_REVIEW Completeness and eligibility screen (full board vs. expedited) IRB administrator
UNDER_REVIEW Substantive scientific/ethical review IRB members
REVISIONS_REQUESTED Modifications or clarifications required Study team responds
APPROVED Approval granted, valid for a defined period IRB chair
CONTINUING_REVIEW Periodic re-review of an active approval IRB members
SUSPENDED Approval halted pending action IRB
EXPIRED Approval period lapsed without continuing review System
WITHDRAWN Packet pulled by the submitter Study team
CLOSED Study ended, file archived IRB

The two control points that catch most teams off guard are REVISIONS_REQUESTED and CONTINUING_REVIEW. Revisions form a loop: a packet can cycle between review and revisions several times before approval, and each cycle must preserve the full comment-and-response history. Continuing review is a recurring obligation — under ICH E6(R2) §3.1.4 and 21 CFR 56.109(f), the IRB re-reviews ongoing research at intervals appropriate to the degree of risk, at least annually. Missing that window pushes an active, approved study into EXPIRED, which can require halting enrollment. Modeling continuing review as a first-class state (rather than an afterthought) is what makes expiry preventable.

State Diagram for Ethics Review

The lifecycle and its legal transitions are best expressed as a state machine. Note the revision loop and the recurring continuing-review cycle:

stateDiagram-v2
    [*] --> DRAFT
    DRAFT --> SUBMITTED: packet complete
    DRAFT --> WITHDRAWN: abandoned
    SUBMITTED --> ADMIN_REVIEW: intake accepts
    SUBMITTED --> DRAFT: returned incomplete
    ADMIN_REVIEW --> UNDER_REVIEW: eligible for review
    ADMIN_REVIEW --> DRAFT: not eligible
    UNDER_REVIEW --> REVISIONS_REQUESTED: modifications needed
    UNDER_REVIEW --> APPROVED: conditions met
    UNDER_REVIEW --> WITHDRAWN: sponsor withdraws
    REVISIONS_REQUESTED --> UNDER_REVIEW: response submitted
    REVISIONS_REQUESTED --> WITHDRAWN: sponsor withdraws
    APPROVED --> CONTINUING_REVIEW: review window opens
    APPROVED --> EXPIRED: window missed
    APPROVED --> CLOSED: study ends
    CONTINUING_REVIEW --> APPROVED: re-approved
    CONTINUING_REVIEW --> SUSPENDED: concerns raised
    CONTINUING_REVIEW --> CLOSED: study ends
    SUSPENDED --> APPROVED: issues resolved
    SUSPENDED --> CLOSED: terminated
    EXPIRED --> CLOSED: file closed
    WITHDRAWN --> [*]
    CLOSED --> [*]

Every arrow in this diagram is a legal transition; every pair of states with no arrow between them is forbidden. A submission can never jump from SUBMITTED straight to APPROVED — it must pass through ADMIN_REVIEW and UNDER_REVIEW. Encoding the diagram as data (a transition table) rather than as scattered if statements is the key to keeping the rules auditable and the code small.

Central vs. Local IRB: One Workflow, Two Shapes

A single trial often runs both review models simultaneously, and the workflow engine must accommodate both without forking the state machine.

  • Central (single) IRB. Under the FDA’s single-IRB expectation for multi-site studies and the NIH single-IRB policy, one board reviews the master protocol and consent template once. The states above apply at the protocol level, and individual sites inherit the approval. The orchestration challenge is fan-out: propagating one APPROVED event to many site records and tracking site-specific local context (local PI, local consent language, local recruitment materials).
  • Local IRB/IEC. Each institution’s board runs its own instance of the lifecycle in parallel. Here the challenge is fan-in: a site is not “activated” until its local approval reaches APPROVED, and continuing-review clocks run independently per site.

The same WorkflowState enum and transition table govern both — the difference is the cardinality of the state objects (one per protocol vs. one per site) and the events that link them. Keeping a single transition table avoids the classic bug where central and local paths drift apart and a site is marked active on the strength of a central approval it never actually inherited locally.

GCP / ICH E6 Alignment

The state machine is only valuable if each state and transition maps cleanly to a regulatory obligation. The alignment below keeps the model honest:

  • Documented decisions. ICH E6(R2) §3.1.2 requires the IRB/IEC to conduct its review and document its decisions. Every transition into APPROVED, REVISIONS_REQUESTED, SUSPENDED, or CLOSED must carry the board’s documented opinion as an attached artifact.
  • Approval before enrollment. A site may only enroll once it is in APPROVED (or has re-entered it via CONTINUING_REVIEW). Downstream activation systems should read this state as the gating signal — see Clinical Site Readiness Assessment Frameworks.
  • Continuing review at appropriate intervals. Per §3.1.4, the CONTINUING_REVIEW cycle must trigger at a risk-appropriate cadence (at least annually). The engine owns the timer; the IRB owns the decision.
  • Audit trail. 21 CFR Part 11 and ICH E6(R2) §2.10 require attributable, contemporaneous, original, and accurate (ALCOA+) records. Each transition is recorded as an immutable, timestamped event with the acting identity.

Encoding the Transition Table in Python

The implementation centers on two ideas: a WorkflowState enum (the states) and a transition table (the legal moves). Any attempt to move outside the table raises an error before mutating state, so an invalid transition can never be persisted.

"""Deterministic finite-state machine for IRB/IEC ethics review."""
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Iterable

logger = logging.getLogger("irb_ethics_fsm")


class WorkflowState(str, Enum):
    DRAFT = "DRAFT"
    SUBMITTED = "SUBMITTED"
    ADMIN_REVIEW = "ADMIN_REVIEW"
    UNDER_REVIEW = "UNDER_REVIEW"
    REVISIONS_REQUESTED = "REVISIONS_REQUESTED"
    APPROVED = "APPROVED"
    CONTINUING_REVIEW = "CONTINUING_REVIEW"
    SUSPENDED = "SUSPENDED"
    EXPIRED = "EXPIRED"
    WITHDRAWN = "WITHDRAWN"
    CLOSED = "CLOSED"


# The transition table is the single source of truth for legal moves.
# Every key/value pair mirrors an arrow in the state diagram; any move
# not listed here is, by definition, illegal.
VALID_TRANSITIONS: dict[WorkflowState, frozenset[WorkflowState]] = {
    WorkflowState.DRAFT: frozenset({WorkflowState.SUBMITTED, WorkflowState.WITHDRAWN}),
    WorkflowState.SUBMITTED: frozenset({WorkflowState.ADMIN_REVIEW, WorkflowState.DRAFT}),
    WorkflowState.ADMIN_REVIEW: frozenset({WorkflowState.UNDER_REVIEW, WorkflowState.DRAFT}),
    WorkflowState.UNDER_REVIEW: frozenset(
        {WorkflowState.REVISIONS_REQUESTED, WorkflowState.APPROVED, WorkflowState.WITHDRAWN}
    ),
    WorkflowState.REVISIONS_REQUESTED: frozenset(
        {WorkflowState.UNDER_REVIEW, WorkflowState.WITHDRAWN}
    ),
    WorkflowState.APPROVED: frozenset(
        {WorkflowState.CONTINUING_REVIEW, WorkflowState.EXPIRED, WorkflowState.CLOSED}
    ),
    WorkflowState.CONTINUING_REVIEW: frozenset(
        {WorkflowState.APPROVED, WorkflowState.SUSPENDED, WorkflowState.CLOSED}
    ),
    WorkflowState.SUSPENDED: frozenset({WorkflowState.APPROVED, WorkflowState.CLOSED}),
    WorkflowState.EXPIRED: frozenset({WorkflowState.CLOSED}),
    WorkflowState.WITHDRAWN: frozenset(),  # terminal
    WorkflowState.CLOSED: frozenset(),      # terminal
}


class IllegalTransitionError(RuntimeError):
    """Raised when a transition is not permitted by VALID_TRANSITIONS."""


@dataclass(frozen=True)
class TransitionEvent:
    """An immutable, ALCOA+-aligned audit record for one transition."""

    submission_id: str
    state_from: WorkflowState
    state_to: WorkflowState
    actor: str
    reason: str
    timestamp: datetime


@dataclass
class EthicsReview:
    """Tracks a single IRB/IEC review (per protocol or per site)."""

    submission_id: str
    state: WorkflowState = WorkflowState.DRAFT
    history: list[TransitionEvent] = field(default_factory=list)

    def can_transition(self, target: WorkflowState) -> bool:
        return target in VALID_TRANSITIONS[self.state]

    def transition(self, target: WorkflowState, actor: str, reason: str) -> TransitionEvent:
        """Move to ``target`` if legal, recording an immutable audit event.

        Raises:
            IllegalTransitionError: if the move is not in the transition table.
        """
        if not actor:
            raise ValueError("actor is required for an attributable audit trail")
        if not self.can_transition(target):
            allowed = sorted(s.value for s in VALID_TRANSITIONS[self.state])
            raise IllegalTransitionError(
                f"{self.submission_id}: {self.state.value} -> {target.value} "
                f"is not permitted; allowed: {allowed}"
            )
        event = TransitionEvent(
            submission_id=self.submission_id,
            state_from=self.state,
            state_to=target,
            actor=actor,
            reason=reason,
            timestamp=datetime.now(timezone.utc),
        )
        self.state = target
        self.history.append(event)
        logger.info(
            "transition", extra={"submission_id": self.submission_id,
                                 "from": event.state_from.value,
                                 "to": event.state_to.value, "actor": actor}
        )
        return event

    def is_active(self) -> bool:
        """True when the site may enroll: approved and not expired/suspended."""
        return self.state is WorkflowState.APPROVED


def replay(events: Iterable[TransitionEvent]) -> WorkflowState:
    """Reconstruct the final state from an event log (audit reconstruction)."""
    state: WorkflowState | None = None
    for event in events:
        if state is not None and event.state_from is not state:
            raise IllegalTransitionError(
                f"event log gap: expected {state}, got {event.state_from}"
            )
        if state is not None and event.state_to not in VALID_TRANSITIONS[state]:
            raise IllegalTransitionError(
                f"illegal event in log: {event.state_from} -> {event.state_to}"
            )
        state = event.state_to
    return state if state is not None else WorkflowState.DRAFT

Usage is intentionally boring — which is the point. Legal moves succeed; illegal ones raise before any state changes:

review = EthicsReview(submission_id="STUDY-001-SITE-014")
review.transition(WorkflowState.SUBMITTED, actor="coordinator", reason="packet complete")
review.transition(WorkflowState.ADMIN_REVIEW, actor="irb_admin", reason="intake accepted")
review.transition(WorkflowState.UNDER_REVIEW, actor="irb_admin", reason="assigned to board")

# A forbidden shortcut is rejected, protecting the audit trail:
try:
    review.transition(WorkflowState.APPROVED, actor="someone", reason="skip review")
except IllegalTransitionError as exc:
    logger.warning("blocked illegal transition: %s", exc)

The replay function is what makes this design defensible in an inspection: given only the append-only event log, you can deterministically reconstruct the current state and prove that no illegal transition ever occurred. That property — derive state purely from a validated event history — is the bridge from a workflow toy to a Part 11-grade record.

From Mapping to Production

A clean state machine is the core, but a deployable ethics workflow needs three more layers around it:

  • Validation gates that block a packet from leaving DRAFT until protocol, consent, and delegation artifacts pass schema checks — see FDA/EMA Submission Schema Design.
  • Resilient delivery to IRB portals that survives timeouts and outages without losing or duplicating submissions — see Configuring fallback routing when clinical portals timeout.
  • Continuing-review timers that open the CONTINUING_REVIEW window before approval lapses, preventing avoidable EXPIRED transitions.

With these in place, the same model that documents your ethics workflow also enforces it. For the step-by-step build — predicates, guards, persistence, and timers — continue to How to map IRB submission workflows to automated state machines.

FAQ

Why model IRB review as a state machine instead of a status field?

A free-text status field lets any value be set from any other value, so nothing prevents a packet jumping from SUBMITTED to APPROVED without review. A finite-state machine encodes the legal transitions in a table and rejects everything else before persisting, which both prevents process violations and produces an audit trail that maps directly to GCP review steps.

How does the model handle the revision loop?

UNDER_REVIEW and REVISIONS_REQUESTED form a bidirectional cycle: the board can request revisions and the study team can resubmit any number of times. Each pass is recorded as its own transition event, so the complete comment-and-response history is preserved rather than overwritten.

How is continuing review represented?

CONTINUING_REVIEW is a distinct state reachable only from APPROVED. A timer (owned by the workflow engine, not the IRB) opens the continuing-review window at a risk-appropriate interval per ICH E6(R2) §3.1.4. Re-approval returns the study to APPROVED; a missed window transitions it to EXPIRED, which downstream systems treat as a stop-enrollment signal.

Does one state machine work for both central and local IRBs?

Yes. The states and transition table are identical; only the cardinality differs. A central IRB produces one state object per protocol that fans out to sites, while local IRBs produce one state object per site that must independently reach APPROVED. Sharing one transition table keeps the two paths from diverging.