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 guide 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.
Problem framing: why a status field is not enough
A free-text or single-column status field lets any value be assigned from any other value. Nothing structural stops a packet jumping from SUBMITTED to APPROVED without review, nothing forces a documented opinion to accompany a decision, and nothing records who moved the packet or when. The compliance stakes are concrete: an approval that cannot be reconstructed from an attributable record is an inspection finding, and a site enrolling on a lapsed approval is a reportable deviation.
Treating the review process as a finite-state machine (FSM) fixes all three at the root. The current state is explicit, every transition is a recorded event, and illegal sequences (approving a packet that was never reviewed) are rejected before any state changes. The ethics workflow is the ordering authority for clinical site readiness assessment: a site is a candidate for activation only when its review reaches APPROVED, and every transition it makes is written to the append-only audit log that 21 CFR Part 11 requires. For the full engine — guards, event sourcing, hash-chained records, and central/local scoping — the deep build lives in How to map IRB submission workflows to automated state machines.
The 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.
Decision flowchart for ethics review
The lifecycle and its legal transitions are best expressed as a state machine. The branching logic below routes a packet by its current state and the event that arrives — note the revision loop and the recurring continuing-review cycle:
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. When the two boards use different names for the same milestone, normalize them through regulatory taxonomy standardization before they reach the state machine, so one canonical event vocabulary drives both instances.
Library and tooling landscape
An ethics FSM is small, regulated policy logic — the wrong instinct is to reach for a heavyweight workflow framework whose transition rules live in configuration an inspector cannot read. The clinical-grade recommendation is plain typed Python: an enum for states, a dict transition table for the legal moves, dataclasses for the audit records, and standard-library logging for the trail. Everything is version-pinnable, diff-reviewable, and unit-testable.
| Option | Role | Clinical-grade fit |
|---|---|---|
enum + dict transition table (stdlib) |
States and legal moves as reviewable data | Recommended. Transparent and typed; the transition table reads like the regulatory spec and a non-programmer can audit it. |
dataclasses + logging (stdlib) |
Immutable audit events, structured trail | Recommended. Frozen dataclasses give tamper-resistant records; logging ships them to append-only storage. |
python-statemachine |
Declarative FSM with callbacks | Situational. Fine for UI-driven flows; adds a dependency and hides the transition table behind decorators, which weakens the “rules as reviewable data” property. |
transitions |
General-purpose FSM library | Situational. Capable, but its callback and nested-state machinery is more surface area than a regulated lifecycle needs, and guards live in code scattered across handlers. |
django-fsm |
Model-field FSM for Django ORM | Deprecated — do not use. The original package is unmaintained; migrate to the maintained django-fsm-2 fork if you must couple state to an ORM field, but prefer keeping the transition table framework-independent. |
| Free-text status column | The status quo | Not defensible. Any value settable from any value, no legal-transition enforcement, no attributable event trail. |
Deprecated tooling warning. Do not build new work on
django-fsm; it is unmaintained. The maintained successor isdjango-fsm-2. Better still, keep the transition table in framework-independent Python (as below) and treat any ORM integration as a thin persistence layer, so your legal-transition rules are not hostage to a web framework’s release cycle.
Step-by-step implementation
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. All configuration — including the continuing-review interval — is read from the environment rather than hardcoded.
1. Read configuration from the environment
The continuing-review cadence is policy, not code: regulatory affairs must be able to tighten it for higher-risk studies without a deployment. Read it from an environment variable so nothing risk-related is baked into the binary.
"""Deterministic finite-state machine for IRB/IEC ethics review."""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Iterable
logger = logging.getLogger("irb_ethics_fsm")
# Policy value, never hardcoded: how long an approval is valid before a
# continuing review must open. Defaults to the annual regulatory floor.
CONTINUING_REVIEW_DAYS = int(os.environ.get("IRB_CONTINUING_REVIEW_DAYS", "365"))
2. Enumerate the states
Each canonical stage becomes an enum member. Subclassing str keeps the values JSON-serializable for the audit log without extra conversion code.
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"
3. Encode the legal transitions as data
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 is, by definition, illegal. This is the artifact a regulatory-affairs analyst can review without reading the surrounding Python.
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."""
4. Record each transition as an immutable audit event
Every legal move produces a frozen TransitionEvent. The EthicsReview object validates before it mutates: an invalid transition raises before any state change, so a rejected move never pollutes the trail. Each successful move is logged with the acting identity, satisfying the Attributable and Contemporaneous attributes of ALCOA+.
@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
5. Reconstruct state by replaying the event log
The replay function derives the current state purely from the append-only event history, re-validating every step against the transition table. This is what makes the design defensible in an inspection: given only the log, you can prove no illegal transition ever occurred.
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)
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 — it is the ethics gate that clinical site readiness assessment treats as a hard, non-negotiable prerequisite. - Continuing review at appropriate intervals. Per §3.1.4, the
CONTINUING_REVIEWcycle must trigger at a risk-appropriate cadence (at least annually), driven byCONTINUING_REVIEW_DAYS. 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.
Validation and audit-trail integration
A transition event is not just an in-memory record — it is a regulated artifact that feeds the platform’s append-only audit log. Two integration points keep the ethics workflow honest end to end.
Inbound validation. Nothing should leave DRAFT until the protocol, consent template, and delegation artifacts are structurally valid. Those checks belong to schema validation and error categorization: route each artifact through it, and only flip the packet toward SUBMITTED once every required document validates. This keeps malformed packets out of the review queue instead of surfacing them as revision cycles.
Outbound audit. Persist each TransitionEvent as a JSON Lines record to write-once (WORM) storage so history cannot be retroactively edited — that is what satisfies the Enduring and Available attributes of the ALCOA+ chain. Because replay() re-derives the current state from that log alone, the stored “current state” is only ever a cache that must agree with the reconstruction. The replay guarantee — derive state purely from a validated event history — is the bridge from a workflow toy to a Part 11-grade record. Approved-state artifacts (the IRB approval letter, the version-stamped consent) then flow onward into FDA/EMA submission schema design for downstream filing.
Error categorization and recovery
Not every failed or stalled review means the same thing, and conflating them either blocks activation needlessly or lets a site slip through on a lapsed approval. Classify each failure so the recovery path is proportionate and routable to the right owner.
| Failure class | How to detect it programmatically | Recovery strategy |
|---|---|---|
| Illegal transition attempt | transition() raises IllegalTransitionError |
Reject before mutation; log the rejected attempt separately from the state log and surface it to the caller — never coerce the move through. |
| Revision loop churn | Repeated UNDER_REVIEW ↔ REVISIONS_REQUESTED events in history |
Preserve every cycle as its own event; escalate to a coordinator when the count exceeds a threshold rather than overwriting prior comments. |
| Missed continuing review | Clock exceeds CONTINUING_REVIEW_DAYS while still in APPROVED |
Open CONTINUING_REVIEW proactively before lapse; if the window is truly missed, transition to EXPIRED and signal downstream systems to stop enrollment. |
| Submission delivery failure | IRB portal timeout or 5xx on transmit | Do not advance state on a failed send; hand off to fallback routing for portal outages so the packet is neither lost nor duplicated. |
| Central/local drift | A site marked active whose local instance never reached APPROVED |
Reconcile per-instance replay() results before activation; a central approval never substitutes for a missing local one. |
| Event-log gap or tamper | replay() raises on a from/to mismatch |
Treat as an integrity incident; the log is the source of truth, so a gap means a write was lost or altered and must be investigated, not patched. |
The governing rule is that no bad outcome is ever resolved by editing the state directly. State changes only through a legal, recorded transition — and each of those is itself an audit event, so the recovery path stays as traceable as the happy path.
Compliance checklist
- Encode legal moves in the
VALID_TRANSITIONStable, not scatteredifstatements, so the rules are reviewable data. - Reject any move absent from the table before mutating state; log rejected attempts separately from successful transitions.
- Require an
actoron every transition so each event is Attributable (ALCOA+) and 21 CFR Part 11 §11.10(e) compliant. - Stamp each event with a UTC timestamp at the moment of transition (Contemporaneous), never backfilled.
- Read the continuing-review interval from an environment variable; never hardcode risk-related policy.
- Attach the board’s documented opinion to every
APPROVED,REVISIONS_REQUESTED,SUSPENDED, andCLOSEDtransition (ICH E6(R2) §3.1.2). - Persist the event log to append-only WORM storage and derive current state via
replay()(Original, Enduring, Available). - Gate enrollment on the
APPROVEDstate only, and reconcile central vs. local instances before marking a site active. - Open
CONTINUING_REVIEWbefore the approval window lapses so avoidableEXPIREDtransitions never occur.
From mapping to production
A clean state machine is the core, but a deployable ethics workflow adds three layers around it: validation gates that block a packet from leaving DRAFT until artifacts pass schema checks, resilient delivery to IRB portals that survives outages without losing or duplicating submissions, and continuing-review timers that open the window before approval lapses. With these in place, the same model that documents your ethics workflow also enforces it. For the step-by-step build — event sourcing, guard-before-mutate semantics, hash-chained records, and central/local scoping — 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, configured by CONTINUING_REVIEW_DAYS. 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.
Related
- How to map IRB submission workflows to automated state machines — the full engine: event sourcing, guards, and hash-chained audit records.
- Clinical Site Readiness Assessment Frameworks — treats the
APPROVEDethics state as a hard activation gate. - Regulatory Taxonomy Standardization — normalizes central vs. local milestone names into one canonical event vocabulary.
- Schema Validation & Error Categorization — validates packet artifacts before they leave
DRAFT. - Configuring fallback routing when clinical portals timeout — resilient delivery so a failed transmit never advances state.
Up one level: this is one domain of Core Architecture & Regulatory Mapping for Clinical Trials.