How to Map IRB Submission Workflows to Automated State Machines
Mapping an Institutional Review Board submission lifecycle onto a finite-state machine turns an ambiguous, email-driven approval process into a deterministic, auditable engine. This guide builds a complete IRB FSM in Python — explicit transition table, guard-before-mutate semantics, an event-sourced immutable audit log with replay and verification, and 21 CFR Part 11 / ALCOA+ alignment for both central and local IRBs.
The core insight is that an IRB workflow is not a checklist; it is a directed graph of legal state transitions. If your code lets a study jump from DRAFT straight to APPROVED, or lets a reviewer mutate an APPROVED record without a fresh continuing-review event, you have a compliance defect, not just a bug. A finite-state machine (FSM) makes the legal graph explicit and makes every illegal move raise loudly instead of silently corrupting state. This page sits under the IRB/Ethics Workflow Mapping cluster within the Core Architecture & Regulatory Mapping for Clinical Trials pillar.
The IRB submission lifecycle as states
The lifecycle of a single protocol submission to one IRB has a small, well-defined set of states. Resist the urge to encode metadata (revision counts, reviewer names, deadlines) as states — those are attributes of the workflow context, not states. A state is a stable condition the submission rests in while waiting for the next event.
| State | Meaning | Terminal? |
|---|---|---|
DRAFT |
Study team is assembling the package; not yet sent. | No |
SUBMITTED |
Package transmitted to the IRB; awaiting intake acceptance. | No |
UNDER_REVIEW |
IRB has accepted intake and is reviewing. | No |
REVISIONS_REQUESTED |
IRB returned the package for changes. | No |
APPROVED |
Active approval; site work may proceed. | No |
CONTINUING_REVIEW |
Approval period nearing expiry; re-review in progress. | No |
EXPIRED |
Approval lapsed without timely continuing review. | Terminal |
WITHDRAWN |
Study team withdrew the submission. | Terminal |
DISAPPROVED |
IRB formally disapproved the protocol. | Terminal |
The active loop is DRAFT → SUBMITTED → UNDER_REVIEW ↔ REVISIONS_REQUESTED → APPROVED. Approval is not terminal in clinical practice: US regulation (45 CFR 46.109 and FDA 21 CFR 56.109) requires continuing review at intervals appropriate to the degree of risk, so APPROVED cycles through CONTINUING_REVIEW and back. Only EXPIRED, WITHDRAWN, and DISAPPROVED are terminal — once entered, no further transition is legal.
The transition table is the contract
Everything else in the system is subordinate to one data structure: the transition table. It enumerates, for every (current_state, event) pair, the single resulting state. Any pair absent from the table is — by definition — an illegal transition that must raise. This is the difference between a state machine and a pile of if statements: the legality of a move is data, reviewable by a regulatory-affairs analyst who cannot read Python.
stateDiagram-v2
[*] --> DRAFT
DRAFT --> SUBMITTED: SUBMIT
DRAFT --> WITHDRAWN: WITHDRAW
SUBMITTED --> UNDER_REVIEW: ACCEPT_INTAKE
SUBMITTED --> REVISIONS_REQUESTED: REJECT_INTAKE
SUBMITTED --> WITHDRAWN: WITHDRAW
UNDER_REVIEW --> REVISIONS_REQUESTED: REQUEST_REVISIONS
UNDER_REVIEW --> APPROVED: APPROVE
UNDER_REVIEW --> DISAPPROVED: DISAPPROVE
UNDER_REVIEW --> WITHDRAWN: WITHDRAW
REVISIONS_REQUESTED --> SUBMITTED: RESUBMIT
REVISIONS_REQUESTED --> WITHDRAWN: WITHDRAW
APPROVED --> CONTINUING_REVIEW: OPEN_CONTINUING_REVIEW
APPROVED --> EXPIRED: LAPSE
APPROVED --> WITHDRAWN: WITHDRAW
CONTINUING_REVIEW --> APPROVED: APPROVE
CONTINUING_REVIEW --> REVISIONS_REQUESTED: REQUEST_REVISIONS
CONTINUING_REVIEW --> DISAPPROVED: DISAPPROVE
CONTINUING_REVIEW --> EXPIRED: LAPSE
EXPIRED --> [*]
WITHDRAWN --> [*]
DISAPPROVED --> [*]
The diagram above is a faithful rendering of the TRANSITIONS table in the code below — every edge in one appears in the other, and there are no extra edges in either. Keeping the diagram and the table in lockstep is itself a review artifact: a divergence between them is a code-review red flag.
Guard before mutate
A transition has three phases, and the order is non-negotiable:
- Validate the
(state, event)pair against the table. If absent, raise. - Guard: evaluate event-specific preconditions (signature present, mandatory documents attached, actor authorized). If any fails, raise before touching state.
- Mutate: only after validation and guards pass, append the event to the log and advance the state.
If you mutate first and validate later, a failed guard leaves the workflow in a corrupt intermediate state and pollutes the audit trail with a transition that never legitimately happened. Guard-before-mutate keeps the audit log a truthful record of successful transitions only — failed attempts are logged separately as rejected events, not as state changes.
Event sourcing for an immutable audit trail
For 21 CFR Part 11 §11.10(e), the audit trail must be secure, computer-generated, time-stamped, and must not obscure previously recorded information. The cleanest way to guarantee this is event sourcing: the current state is never stored as the source of truth. Instead, an append-only sequence of events is the source of truth, and the current state is derived by replaying those events. You cannot edit history because history is the only thing that exists; an “edit” is just another appended event.
Each event is hash-chained to its predecessor (each record stores the digest of the prior record, Merkle-style), so any tampering with a past record breaks every subsequent digest and is detectable on verification. This satisfies ALCOA+ directly: every event is Attributable (actor captured), Legible (structured JSON), Contemporaneous (UTC timestamp at append), Original (append-only), and Accurate (hash-verified), plus Complete, Consistent, Enduring, and Available through deterministic replay.
A critical distinction: the hash chain provides integrity (proof the record was not altered). It is not an electronic signature. A Part 11 e-signature is a separate, authenticated act binding a named human to a specific record with intent (the “signed by / meaning” components of §11.50). The code below stores an esignature_ref — an opaque reference to a signature captured by a dedicated, authenticated signing service — and never conflates that with the integrity digest.
Central versus local IRB
A multi-site trial may rely on a central IRB (a single IRB of record for many sites, now the NIH default for multi-site studies under the single-IRB policy) or on local IRBs (each site’s own board). The FSM itself is identical for both — the lifecycle of a submission does not change. What changes is scope: one workflow instance per IRB-of-record. Model this with a ReviewScope and an irb_id, so a central-IRB study has one FSM instance while a local-IRB study has one instance per site. The audit log keys on the workflow instance, keeping each board’s record independently verifiable.
Production implementation
"""Finite-state machine for the IRB submission lifecycle.
Event-sourced: the append-only event log is the source of truth and the
current state is derived by replaying it. Transitions are guarded before
any mutation, and the log is hash-chained for tamper evidence in line with
21 CFR Part 11 and ALCOA+.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field, replace as dataclass_replace
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Mapping
class State(str, Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
UNDER_REVIEW = "UNDER_REVIEW"
REVISIONS_REQUESTED = "REVISIONS_REQUESTED"
APPROVED = "APPROVED"
CONTINUING_REVIEW = "CONTINUING_REVIEW"
EXPIRED = "EXPIRED"
WITHDRAWN = "WITHDRAWN"
DISAPPROVED = "DISAPPROVED"
class Event(str, Enum):
SUBMIT = "SUBMIT"
ACCEPT_INTAKE = "ACCEPT_INTAKE"
REJECT_INTAKE = "REJECT_INTAKE"
REQUEST_REVISIONS = "REQUEST_REVISIONS"
RESUBMIT = "RESUBMIT"
APPROVE = "APPROVE"
DISAPPROVE = "DISAPPROVE"
OPEN_CONTINUING_REVIEW = "OPEN_CONTINUING_REVIEW"
LAPSE = "LAPSE"
WITHDRAW = "WITHDRAW"
class ReviewScope(str, Enum):
CENTRAL = "CENTRAL" # single IRB of record for many sites
LOCAL = "LOCAL" # one IRB per site
TERMINAL_STATES: frozenset[State] = frozenset(
{State.EXPIRED, State.WITHDRAWN, State.DISAPPROVED}
)
# The transition table IS the contract. Any (state, event) pair not present
# here is an illegal transition and must raise. Mirrors the mermaid diagram.
TRANSITIONS: Mapping[tuple[State, Event], State] = {
(State.DRAFT, Event.SUBMIT): State.SUBMITTED,
(State.DRAFT, Event.WITHDRAW): State.WITHDRAWN,
(State.SUBMITTED, Event.ACCEPT_INTAKE): State.UNDER_REVIEW,
(State.SUBMITTED, Event.REJECT_INTAKE): State.REVISIONS_REQUESTED,
(State.SUBMITTED, Event.WITHDRAW): State.WITHDRAWN,
(State.UNDER_REVIEW, Event.REQUEST_REVISIONS): State.REVISIONS_REQUESTED,
(State.UNDER_REVIEW, Event.APPROVE): State.APPROVED,
(State.UNDER_REVIEW, Event.DISAPPROVE): State.DISAPPROVED,
(State.UNDER_REVIEW, Event.WITHDRAW): State.WITHDRAWN,
(State.REVISIONS_REQUESTED, Event.RESUBMIT): State.SUBMITTED,
(State.REVISIONS_REQUESTED, Event.WITHDRAW): State.WITHDRAWN,
(State.APPROVED, Event.OPEN_CONTINUING_REVIEW): State.CONTINUING_REVIEW,
(State.APPROVED, Event.LAPSE): State.EXPIRED,
(State.APPROVED, Event.WITHDRAW): State.WITHDRAWN,
(State.CONTINUING_REVIEW, Event.APPROVE): State.APPROVED,
(State.CONTINUING_REVIEW, Event.REQUEST_REVISIONS): State.REVISIONS_REQUESTED,
(State.CONTINUING_REVIEW, Event.DISAPPROVE): State.DISAPPROVED,
(State.CONTINUING_REVIEW, Event.LAPSE): State.EXPIRED,
}
class IllegalTransitionError(Exception):
"""Raised when a (state, event) pair is not in the transition table."""
class GuardFailedError(Exception):
"""Raised when an event's precondition (guard) is not satisfied."""
class AuditIntegrityError(Exception):
"""Raised when the hash chain fails verification."""
@dataclass(frozen=True)
class Actor:
"""Authenticated principal performing an action (ALCOA+ Attributable)."""
user_id: str
full_name: str
role: str # e.g. PI, COORDINATOR, IRB_ANALYST, IRB_CHAIR
@dataclass(frozen=True)
class AuditEvent:
"""Immutable, hash-chained audit record. The genesis record uses an
all-zero prev_hash. `record_hash` covers every field except itself."""
sequence: int
workflow_id: str
irb_id: str
scope: ReviewScope
event: Event
from_state: State
to_state: State
actor: Actor
reason: str
payload_digest: str # sha256 of the submission package
esignature_ref: str | None # opaque ref to a Part 11 e-signature, NOT the chain hash
timestamp_utc: str
prev_hash: str
record_hash: str = ""
def digest_input(self) -> bytes:
"""Canonical bytes hashed into record_hash (excludes record_hash)."""
body = {
"sequence": self.sequence,
"workflow_id": self.workflow_id,
"irb_id": self.irb_id,
"scope": self.scope.value,
"event": self.event.value,
"from_state": self.from_state.value,
"to_state": self.to_state.value,
"actor": {
"user_id": self.actor.user_id,
"full_name": self.actor.full_name,
"role": self.actor.role,
},
"reason": self.reason,
"payload_digest": self.payload_digest,
"esignature_ref": self.esignature_ref,
"timestamp_utc": self.timestamp_utc,
"prev_hash": self.prev_hash,
}
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
def compute_hash(self) -> str:
return hashlib.sha256(self.digest_input()).hexdigest()
GENESIS_HASH = "0" * 64
# A guard receives the proposed context and raises GuardFailedError on failure.
Guard = Callable[["TransitionContext"], None]
@dataclass(frozen=True)
class TransitionContext:
actor: Actor
reason: str
payload_digest: str
esignature_ref: str | None
documents_attached: bool
def _require_signature(ctx: TransitionContext) -> None:
if not ctx.esignature_ref:
raise GuardFailedError("A 21 CFR Part 11 e-signature is required for this event.")
def _require_documents(ctx: TransitionContext) -> None:
if not ctx.documents_attached:
raise GuardFailedError("Mandatory submission documents are not attached.")
def _require_role(*roles: str) -> Guard:
allowed = frozenset(roles)
def guard(ctx: TransitionContext) -> None:
if ctx.actor.role not in allowed:
raise GuardFailedError(
f"Role {ctx.actor.role!r} may not perform this action; "
f"requires one of {sorted(allowed)}."
)
return guard
# Per-event guards. Events with no entry have no extra preconditions.
GUARDS: Mapping[Event, tuple[Guard, ...]] = {
Event.SUBMIT: (_require_documents, _require_signature,
_require_role("PI", "COORDINATOR")),
Event.RESUBMIT: (_require_documents, _require_signature,
_require_role("PI", "COORDINATOR")),
Event.APPROVE: (_require_role("IRB_CHAIR", "IRB_ANALYST"),),
Event.DISAPPROVE: (_require_role("IRB_CHAIR"),),
Event.REQUEST_REVISIONS: (_require_role("IRB_CHAIR", "IRB_ANALYST"),),
Event.ACCEPT_INTAKE: (_require_role("IRB_ANALYST"),),
Event.REJECT_INTAKE: (_require_role("IRB_ANALYST"),),
}
class IRBWorkflow:
"""One submission workflow for one IRB of record (central or local)."""
def __init__(self, workflow_id: str, irb_id: str, scope: ReviewScope) -> None:
self.workflow_id = workflow_id
self.irb_id = irb_id
self.scope = scope
self._log: list[AuditEvent] = []
@property
def state(self) -> State:
"""Derived from the event log (event sourcing) — never stored directly."""
return self._log[-1].to_state if self._log else State.DRAFT
@property
def log(self) -> tuple[AuditEvent, ...]:
return tuple(self._log)
def fire(self, event: Event, ctx: TransitionContext) -> AuditEvent:
"""Validate -> guard -> mutate. Raises before any mutation on failure."""
current = self.state
if current in TERMINAL_STATES:
raise IllegalTransitionError(
f"{current.value} is terminal; no transitions are permitted."
)
# 1. Validate against the transition table.
key = (current, event)
if key not in TRANSITIONS:
raise IllegalTransitionError(
f"Illegal transition: event {event.value} not allowed in state {current.value}."
)
target = TRANSITIONS[key]
# 2. Guard (raises before mutate).
for guard in GUARDS.get(event, ()):
guard(ctx)
# 3. Mutate: append an immutable, hash-chained record and advance.
prev_hash = self._log[-1].record_hash if self._log else GENESIS_HASH
record = AuditEvent(
sequence=len(self._log),
workflow_id=self.workflow_id,
irb_id=self.irb_id,
scope=self.scope,
event=event,
from_state=current,
to_state=target,
actor=ctx.actor,
reason=ctx.reason,
payload_digest=ctx.payload_digest,
esignature_ref=ctx.esignature_ref,
timestamp_utc=datetime.now(timezone.utc).isoformat(),
prev_hash=prev_hash,
)
record = replace_hash(record)
self._log.append(record)
return record
def replay(self) -> State:
"""Recompute the current state purely from the event log."""
state = State.DRAFT
for rec in self._log:
if rec.from_state != state:
raise AuditIntegrityError(
f"Replay break at seq {rec.sequence}: log says from "
f"{rec.from_state.value} but replay is at {state.value}."
)
if TRANSITIONS.get((state, rec.event)) != rec.to_state:
raise AuditIntegrityError(
f"Replay break at seq {rec.sequence}: "
f"{state.value} --{rec.event.value}--> {rec.to_state.value} is not a legal edge."
)
state = rec.to_state
return state
def verify_chain(self) -> bool:
"""Verify the hash chain end to end. Raises AuditIntegrityError on tamper."""
prev = GENESIS_HASH
for i, rec in enumerate(self._log):
if rec.sequence != i:
raise AuditIntegrityError(f"Sequence gap at index {i}: got {rec.sequence}.")
if rec.prev_hash != prev:
raise AuditIntegrityError(f"Broken chain link at seq {rec.sequence}.")
if rec.record_hash != rec.compute_hash():
raise AuditIntegrityError(f"Record {rec.sequence} has been altered.")
prev = rec.record_hash
return True
def replace_hash(record: AuditEvent) -> AuditEvent:
"""Return a copy of the (frozen) record with record_hash populated."""
return dataclass_replace(record, record_hash=record.compute_hash())
Driving and verifying the machine
The following exercises the happy path through a revision cycle to approval, then proves both replay and chain integrity. Note that state is read back from the log on every access — there is no separate mutable state field to drift out of sync.
def _ctx(role: str, signed: bool = True, docs: bool = True) -> TransitionContext:
return TransitionContext(
actor=Actor(user_id=f"u-{role.lower()}", full_name=f"Test {role}", role=role),
reason="routine processing",
payload_digest=hashlib.sha256(b"submission-package-v1").hexdigest(),
esignature_ref="sig-ref-abc123" if signed else None,
documents_attached=docs,
)
wf = IRBWorkflow(workflow_id="WF-1001", irb_id="CIRB-01", scope=ReviewScope.CENTRAL)
assert wf.state is State.DRAFT
wf.fire(Event.SUBMIT, _ctx("COORDINATOR"))
wf.fire(Event.ACCEPT_INTAKE, _ctx("IRB_ANALYST"))
wf.fire(Event.REQUEST_REVISIONS, _ctx("IRB_ANALYST"))
wf.fire(Event.RESUBMIT, _ctx("PI"))
wf.fire(Event.ACCEPT_INTAKE, _ctx("IRB_ANALYST"))
wf.fire(Event.APPROVE, _ctx("IRB_CHAIR"))
assert wf.state is State.APPROVED
# Continuing review keeps an approved study compliant past its expiry interval.
wf.fire(Event.OPEN_CONTINUING_REVIEW, _ctx("IRB_ANALYST"))
wf.fire(Event.APPROVE, _ctx("IRB_CHAIR"))
assert wf.state is State.APPROVED
# Integrity guarantees.
assert wf.replay() is State.APPROVED
assert wf.verify_chain() is True
# Illegal transition: you cannot approve a DRAFT.
try:
IRBWorkflow("WF-2", "LIRB-9", ReviewScope.LOCAL).fire(Event.APPROVE, _ctx("IRB_CHAIR"))
except IllegalTransitionError as exc:
print("rejected as expected:", exc)
# Guard failure: a coordinator cannot approve, and nothing mutates.
try:
wf.fire(Event.OPEN_CONTINUING_REVIEW, _ctx("COORDINATOR")) # role guard not set -> ok
wf.fire(Event.APPROVE, _ctx("COORDINATOR")) # role guard fails
except GuardFailedError as exc:
print("guard blocked mutation:", exc)
Because guards run before any append, a GuardFailedError leaves wf.log untouched — the audit trail never records a transition that did not legitimately occur. Detecting tampering is equally direct: mutating any stored record’s reason and re-running verify_chain() raises AuditIntegrityError at the altered sequence.
Persistence and WORM storage
In production, _log is not an in-memory list; each AuditEvent is serialized (its digest_input() plus record_hash) and written to append-only storage — a WORM-compliant object store or an append-only table with row-level immutability. On load, reconstruct the workflow by reading records in sequence order, then call verify_chain() and replay() before accepting any new event. Never trust a stored “current state” column; if you keep one as a read-model projection, treat it as a cache that must agree with replay(). Time stamps come from a synchronized clock source, and the esignature_ref resolves against your authenticated signing service rather than being recomputed.
FAQ
Why event sourcing instead of a status column with an audit table?
A mutable status column is the source of truth in most CRUD apps, and the audit table is a side effect that can drift, be skipped, or be edited. Event sourcing inverts this: the append-only log is the truth, and the status is derived by replay(). That makes “obscuring previously recorded information” (the §11.10(e) prohibition) structurally impossible rather than merely discouraged.
Is the hash chain the same as a 21 CFR Part 11 electronic signature?
No. The hash chain proves integrity — that records were not altered after the fact. A Part 11 e-signature is an authenticated, attributable act with associated meaning (the printed name, date/time, and meaning of the signing under §11.50), captured by a dedicated signing workflow. The code stores an esignature_ref to that artifact and keeps it strictly separate from the integrity digest.
How do central and local IRB models change the machine?
The state graph is identical. The difference is the number of workflow instances: a central IRB of record yields one IRBWorkflow for the whole multi-site study, while local IRBs yield one instance per site, each with its own independently verifiable log keyed by irb_id and workflow_id.
What enforces that the mermaid diagram matches the code?
The diagram is a one-to-one rendering of the TRANSITIONS table. In CI you can parse the diagram’s edges and assert set-equality against TRANSITIONS.keys() mapped to their targets, so any drift fails the build before review.
Related reading
- Parent cluster: IRB/Ethics Workflow Mapping
- Parent pillar: Core Architecture & Regulatory Mapping for Clinical Trials
- Sibling: Building FDA eCTD-compliant JSON schemas for clinical trials
- Sibling: Configuring fallback routing when clinical portals timeout