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:

IRB/IEC ethics review as a finite-state machine The canonical review states — DRAFT, SUBMITTED, ADMIN_REVIEW, UNDER_REVIEW, REVISIONS_REQUESTED, APPROVED, CONTINUING_REVIEW, SUSPENDED, EXPIRED, WITHDRAWN, CLOSED — connected only by their legal transitions. The intake pipeline runs top to bottom, the revision cycle loops between UNDER_REVIEW and REVISIONS_REQUESTED, the approval lifecycle cycles between APPROVED and CONTINUING_REVIEW with SUSPENDED and EXPIRED branches, and WITHDRAWN and CLOSED are terminal. States with no arrow between them cannot transition, which is what makes an illegal approval structurally impossible. packet complete intake accepts eligible for review conditions met window opens re-approved study ends modifications needed response submitted sponsor withdraws withdraws abandoned window missed file closed study ends concerns raised issues resolved terminated returned incomplete · not eligible start end DRAFT SUBMITTED ADMIN_REVIEW UNDER_REVIEW REVISIONS_REQUESTED APPROVED CONTINUING_REVIEW SUSPENDED EXPIRED 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. 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.

Central IRB fan-out versus local IRB fan-in Two panels sharing one transition table. Left: a central single IRB reviews the protocol once, reaches APPROVED, and a single APPROVED event fans out to many site records that inherit the approval and hold only local context. Right: each local IRB or IEC runs its own full lifecycle with an independent continuing-review clock, and every local APPROVED must fan in before the trial is fully activated. The difference is cardinality — one protocol-level state object versus one state object per site — and the direction of propagation, not the states themselves. Central (single) IRB fan-out: one approval propagates to many sites Protocol-level review → APPROVED (once) Site 014 inherits approval Site 022 inherits approval Site 037 inherits approval Sites hold only local context — local PI, consent language, recruitment materials. Local IRB / IEC fan-in: every local approval gates activation Site 014 · full lifecycle → APPROVED · own CR clock Site 022 · full lifecycle → APPROVED · own CR clock Site 037 · full lifecycle → APPROVED · own CR clock Trial fully activated needs ALL local APPROVED No site is active on a central approval it never inherited locally — activation waits for every local board.

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 is django-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"

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, 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 — 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_REVIEW cycle must trigger at a risk-appropriate cadence (at least annually), driven by CONTINUING_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_TRANSITIONS table, not scattered if statements, 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 actor on 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, and CLOSED transition (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 APPROVED state only, and reconcile central vs. local instances before marking a site active.
  • Open CONTINUING_REVIEW before the approval window lapses so avoidable EXPIRED transitions 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.

Up one level: this is one domain of Core Architecture & Regulatory Mapping for Clinical Trials.