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
APPROVEDevent 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, orCLOSEDmust 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 viaCONTINUING_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_REVIEWcycle 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
DRAFTuntil 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_REVIEWwindow before approval lapses, preventing avoidableEXPIREDtransitions.
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.