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.
This build sits within IRB/Ethics Workflow Mapping, part of the Core Architecture & Regulatory Mapping for Clinical Trials foundation, and it is the ordering authority for clinical site readiness assessment: a site is a candidate for activation only once its review reaches APPROVED, and every move it makes is written to the append-only audit log that 21 CFR Part 11 requires.
Why a status column fails
The naive implementation is a single status column updated by whatever code path happens to touch the record. It fails in four concrete, inspection-relevant ways:
- Illegal jumps are unstoppable. A free-text or single-column status lets any value overwrite any other. Nothing structural prevents a packet moving from
SUBMITTEDstraight toAPPROVEDwithout a review ever occurring — the exact scenario that produces a critical audit finding. - History is destroyed on every update. An
UPDATE status = 'APPROVED'overwrites the prior value in place. The record can no longer tell you it was onceREVISIONS_REQUESTED, who moved it, or when — which directly violates the §11.10(e) prohibition on obscuring previously recorded information. - Derived truth drifts. When the stored status is the source of truth, any bug, race, or manual
UPDATEsilently corrupts it, and there is no independent way to detect the corruption. - Approval is treated as terminal. A checklist mindset marks
APPROVEDas “done,” but US regulation (45 CFR 46.109 and FDA 21 CFR 56.109) requires continuing review at intervals appropriate to risk. A status that cannot cycle back through re-review lets a study enroll on a lapsed approval — a reportable deviation.
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.
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: continuing review requirements mean 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.
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.
Setup and configuration
The FSM core depends only on the Python standard library, so it runs with no third-party install and no supply-chain surface to vet. Production wiring — durable storage and the signing service — is what you configure through the environment; never hardcode a store URI, service endpoint, or credential.
# Python 3.11+; the FSM core needs no third-party packages.
python -m venv .venv && . .venv/bin/activate
python -m pip install --upgrade pip
Configuration is read once at startup from environment variables and validated eagerly, so a missing value fails fast instead of surfacing mid-transition:
| Variable | Required | Purpose |
|---|---|---|
IRB_AUDIT_STORE_URI |
yes | WORM object store / append-only table the event log persists to. |
IRB_SIGNING_SERVICE_URL |
yes | Authenticated service that issues and resolves each esignature_ref. |
IRB_LOG_LEVEL |
no (default INFO) |
Structured-log verbosity. |
import logging
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
"""Runtime configuration, sourced only from the environment."""
audit_store_uri: str
signing_service_url: str
log_level: str
@classmethod
def from_env(cls) -> "Settings":
try:
return cls(
audit_store_uri=os.environ["IRB_AUDIT_STORE_URI"],
signing_service_url=os.environ["IRB_SIGNING_SERVICE_URL"],
log_level=os.environ.get("IRB_LOG_LEVEL", "INFO"),
)
except KeyError as missing:
raise RuntimeError(
f"Missing required environment variable: {missing.args[0]}"
) from missing
def configure_logging(level: str) -> logging.Logger:
"""Emit one JSON object per line so audit events are machine-parseable."""
logging.basicConfig(
level=level,
format='{"ts":"%(asctime)s","level":"%(levelname)s",'
'"logger":"%(name)s","msg":"%(message)s"}',
)
return logging.getLogger("irb.fsm")
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 state 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())
Validation and edge-case handling
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")) # no role guard -> 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.
Testing and verification
Pin the guarantees down with pytest, so every regression against illegal jumps, guard bypasses, or silent tampering fails the build. These tests reuse the _ctx helper above.
import pytest
def test_happy_path_reaches_approved() -> None:
wf = IRBWorkflow("WF-1", "CIRB-01", ReviewScope.CENTRAL)
for event, role in [
(Event.SUBMIT, "COORDINATOR"),
(Event.ACCEPT_INTAKE, "IRB_ANALYST"),
(Event.APPROVE, "IRB_CHAIR"),
]:
wf.fire(event, _ctx(role))
assert wf.state is State.APPROVED
assert wf.replay() is State.APPROVED
assert wf.verify_chain() is True
def test_illegal_transition_raises_and_does_not_mutate() -> None:
wf = IRBWorkflow("WF-2", "LIRB-9", ReviewScope.LOCAL)
with pytest.raises(IllegalTransitionError):
wf.fire(Event.APPROVE, _ctx("IRB_CHAIR")) # cannot approve a DRAFT
assert wf.log == () # nothing was appended
def test_guard_failure_leaves_log_untouched() -> None:
wf = IRBWorkflow("WF-3", "CIRB-01", ReviewScope.CENTRAL)
wf.fire(Event.SUBMIT, _ctx("COORDINATOR"))
wf.fire(Event.ACCEPT_INTAKE, _ctx("IRB_ANALYST"))
before = len(wf.log)
with pytest.raises(GuardFailedError):
wf.fire(Event.APPROVE, _ctx("COORDINATOR")) # wrong role
assert len(wf.log) == before
def test_missing_signature_blocks_submission() -> None:
wf = IRBWorkflow("WF-4", "CIRB-01", ReviewScope.CENTRAL)
with pytest.raises(GuardFailedError):
wf.fire(Event.SUBMIT, _ctx("COORDINATOR", signed=False))
def test_tamper_is_detected() -> None:
wf = IRBWorkflow("WF-5", "CIRB-01", ReviewScope.CENTRAL)
wf.fire(Event.SUBMIT, _ctx("COORDINATOR"))
# Simulate a storage-layer edit that keeps the stale record_hash.
wf._log[0] = dataclass_replace(wf._log[0], reason="back-dated note")
with pytest.raises(AuditIntegrityError):
wf.verify_chain()
def test_terminal_state_rejects_all_events() -> None:
wf = IRBWorkflow("WF-6", "CIRB-01", ReviewScope.CENTRAL)
wf.fire(Event.WITHDRAW, _ctx("COORDINATOR"))
assert wf.state is State.WITHDRAWN
with pytest.raises(IllegalTransitionError):
wf.fire(Event.SUBMIT, _ctx("COORDINATOR"))
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 the append-only storage named by IRB_AUDIT_STORE_URI — 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 the authenticated signing service at IRB_SIGNING_SERVICE_URL 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 state 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
- Up one level: IRB/Ethics Workflow Mapping
- Foundation: Core Architecture & Regulatory Mapping for Clinical Trials
- Downstream gate: Clinical Site Readiness Assessment Frameworks
- Sibling: Building FDA eCTD-Compliant JSON Schemas for Clinical Trials
- Sibling: Configuring Fallback Routing When Clinical Portals Timeout