Automating Multi-Board IRB Submissions with a State Machine

A multi-site trial rarely clears ethics review through a single board. A central IRB typically reviews the master protocol and consent template once, while each participating institution’s local IRB or IEC runs its own parallel review of the same protocol against local context — local investigator qualifications, local recruitment materials, sometimes local consent language. A site is not a candidate for activation until its own board reaches approval, regardless of what the central board decided. This piece builds the coordinating layer that sits above the single-board lifecycle described in IRB/Ethics Workflow Mapping, part of Core Architecture & Regulatory Mapping for Clinical Trials: a parent state machine that fans a submission out to one child machine per board, aggregates their independent progress, and only unlocks site readiness once every board that must approve has actually done so. The underlying per-board transition rules follow the same engine documented in How to Map IRB Submission Workflows to Automated State Machines; this page is about coordinating many instances of it at once.

Why naive approaches fail

Teams that already have a working single-board state machine often try to stretch it across boards with a shortcut, and each shortcut reintroduces the exact ambiguity the state machine was built to remove:

  • One status field per protocol. Recording a single irb_status on the protocol record cannot represent “central approved, three of five local boards approved, two still under review” at the same time. Something has to overwrite something, and the overwritten information is exactly what an inspector asks for.
  • Treating central approval as sufficient. A site marked active because the central board approved, without checking whether its own local board ever reached approval, is a real deviation, not a paperwork gap — the site has enrolled outside of any board’s jurisdiction over its local conduct.
  • Polling instead of state. Some pipelines re-query every board’s portal on a timer and infer readiness from the last poll, with no record of when each board actually transitioned. That produces a plausible-looking dashboard and an unreconstructable audit trail.
  • Forking the state machine per board type. Duplicating the transition table for “central” and “local” invites the two copies to drift, so a bug fix applied to one path silently never reaches the other.

The fix is structural rather than procedural: one child state machine instance per reviewing board, one parent object that owns the roster of required boards and asks each child for its current state, and a single aggregation rule — every required board must be in its approved state — that never depends on which board happens to answer last.

Architecture overview

A submission enters as a single protocol-level event. The coordinator resolves which boards must review it — one central board plus a local board per participating site — creates a child review instance per board, and thereafter only aggregates: it never re-implements board-level review logic.

Multi-board IRB coordination pipeline Five stages left to right: the submitted protocol, the central IRB review, the local boards reviewing in parallel, an aggregation step reading every board's state, and the site activation gate that opens only when all required boards have approved. Protocol Central IRB Local boards Aggregate Site gate

The central board and the local boards run the identical per-board transition table; only the cardinality differs, and only the coordinator cares which board a given child instance represents.

Setup and configuration

The coordinator needs no third-party dependency beyond the standard library — enum, dataclasses, and logging are enough to make the rules reviewable and the trail attributable. Configuration that is policy, not code, is read from the environment so a regulatory-affairs change (which board is designated central, how long an aggregation check waits before re-polling) never requires a deployment.

python -m venv .venv && source .venv/bin/activate
python -m pip install --upgrade pip
"""Environment-driven configuration for the multi-board coordinator."""
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class CoordinatorConfig:
    central_board_id: str
    activation_requires_central: bool

    @classmethod
    def from_env(cls) -> "CoordinatorConfig":
        def required(name: str) -> str:
            value = os.environ.get(name)
            if not value:
                raise RuntimeError(f"Missing required environment variable: {name}")
            return value

        central = required("IRB_CENTRAL_BOARD_ID")
        requires_central = os.environ.get("IRB_ACTIVATION_REQUIRES_CENTRAL", "true").lower() == "true"
        return cls(central_board_id=central, activation_requires_central=requires_central)

IRB_ACTIVATION_REQUIRES_CENTRAL exists because some single-IRB arrangements delegate the local determination entirely to the central board, while others still require a local board to separately affirm local context (investigator qualifications, recruitment materials) even after central approval. That is a study-design decision, so it is a configuration flag rather than a hardcoded assumption.

Full working implementation

Each board — central or local — is modeled by the same BoardReview child, governed by one transition table. The coordinator owns a roster of boards keyed by a stable board_id and exposes exactly two questions downstream systems actually need answered: is the whole submission fully approved, and which sites remain blocked.

"""Coordinating state machine for multi-board IRB review."""
from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum

logger = logging.getLogger("irb_coordinator")


class BoardKind(str, Enum):
    CENTRAL = "CENTRAL"
    LOCAL = "LOCAL"


class BoardState(str, Enum):
    NOT_SUBMITTED = "NOT_SUBMITTED"
    SUBMITTED = "SUBMITTED"
    UNDER_REVIEW = "UNDER_REVIEW"
    REVISIONS_REQUESTED = "REVISIONS_REQUESTED"
    APPROVED = "APPROVED"
    REJECTED = "REJECTED"
    WITHDRAWN = "WITHDRAWN"


BOARD_TRANSITIONS: dict[BoardState, frozenset[BoardState]] = {
    BoardState.NOT_SUBMITTED: frozenset({BoardState.SUBMITTED}),
    BoardState.SUBMITTED: frozenset({BoardState.UNDER_REVIEW, BoardState.WITHDRAWN}),
    BoardState.UNDER_REVIEW: frozenset(
        {BoardState.REVISIONS_REQUESTED, BoardState.APPROVED, BoardState.REJECTED, BoardState.WITHDRAWN}
    ),
    BoardState.REVISIONS_REQUESTED: frozenset({BoardState.UNDER_REVIEW, BoardState.WITHDRAWN}),
    BoardState.APPROVED: frozenset(),      # terminal for this child; continuing review is a separate concern
    BoardState.REJECTED: frozenset(),      # terminal
    BoardState.WITHDRAWN: frozenset(),     # terminal
}


class IllegalTransitionError(RuntimeError):
    """Raised when a board's requested move is absent from BOARD_TRANSITIONS."""


@dataclass(frozen=True)
class BoardTransitionEvent:
    board_id: str
    state_from: BoardState
    state_to: BoardState
    actor: str
    reason: str
    timestamp: datetime


@dataclass
class BoardReview:
    """One board's independent review of the same protocol."""

    board_id: str
    kind: BoardKind
    site_id: str | None  # None for the central board; required for a local board
    state: BoardState = BoardState.NOT_SUBMITTED
    history: list[BoardTransitionEvent] = field(default_factory=list)

    def transition(self, target: BoardState, actor: str, reason: str) -> BoardTransitionEvent:
        if not actor:
            raise ValueError("actor is required for an attributable audit trail")
        if target not in BOARD_TRANSITIONS[self.state]:
            allowed = sorted(s.value for s in BOARD_TRANSITIONS[self.state])
            raise IllegalTransitionError(
                f"{self.board_id}: {self.state.value} -> {target.value} not permitted; allowed: {allowed}"
            )
        event = BoardTransitionEvent(
            board_id=self.board_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(json.dumps({
            "action": "board_transition",
            "board_id": self.board_id,
            "kind": self.kind.value,
            "site_id": self.site_id,
            "from": event.state_from.value,
            "to": event.state_to.value,
            "actor": actor,
        }, sort_keys=True))
        return event


@dataclass
class CoordinatingSubmission:
    """Parent machine: fans a protocol out to one child BoardReview per board."""

    protocol_id: str
    boards: dict[str, BoardReview] = field(default_factory=dict)
    required_board_ids: frozenset[str] = frozenset()

    def add_board(self, board: BoardReview, required: bool = True) -> None:
        if board.board_id in self.boards:
            raise ValueError(f"board {board.board_id!r} already registered on {self.protocol_id}")
        self.boards[board.board_id] = board
        if required:
            self.required_board_ids = self.required_board_ids | {board.board_id}

    def transition_board(self, board_id: str, target: BoardState, actor: str, reason: str) -> BoardTransitionEvent:
        if board_id not in self.boards:
            raise KeyError(f"{self.protocol_id}: no board registered with id {board_id!r}")
        return self.boards[board_id].transition(target, actor, reason)

    def is_fully_approved(self) -> bool:
        """True only when every required board — central and local — has approved."""
        return all(
            self.boards[board_id].state is BoardState.APPROVED
            for board_id in self.required_board_ids
        )

    def blocked_sites(self) -> list[str]:
        """Sites whose local board has not yet reached APPROVED."""
        return sorted(
            board.site_id
            for board in self.boards.values()
            if board.kind is BoardKind.LOCAL
            and board.board_id in self.required_board_ids
            and board.state is not BoardState.APPROVED
            and board.site_id is not None
        )

Two properties keep this coordinator honest under audit. First, add_board is the only place a board joins the roster, so required_board_ids is always an explicit, reviewable decision rather than an implicit default. Second, is_fully_approved and blocked_sites never inspect history or infer anything — they read the current state of each child, which is itself only ever reached through a guarded transition, so the aggregate answer is always consistent with the last recorded event for every board.

Validation and edge-case handling

Three situations account for almost every coordination defect seen in production multi-board studies:

  • A local board approves before the central board. Some local IRBs will not open their own review until the central board’s approval letter is in hand; others review concurrently. The coordinator does not encode an ordering constraint between boards unless the sponsor’s operating procedure requires one — if it does, add that as an explicit guard in transition_board rather than relying on submission timing, because timing is not an auditable rule.
  • A site added after the coordinator already exists. A trial that adds a site mid-study must register a new BoardReview for that site’s local board and add it to required_board_ids; is_fully_approved immediately reflects the larger roster, so a stale coordinator can never under-report what is still outstanding.
  • A board withdrawn and resubmitted. WITHDRAWN is terminal in the table above by design — a withdrawn board review does not silently reopen. A resubmission after withdrawal creates a new BoardReview instance with a new board_id suffix (for example, board_id-r2), so the withdrawal and the resubmission are both permanently visible in the roster rather than one overwriting the other.

None of these are resolved by mutating state directly on a BoardReview; every change goes through transition, so the coordinator can never report a submission as approved based on a state that was never legally reached.

Testing and verification

The tests that matter for a coordinator are not about any single board’s transitions — those are covered where the per-board engine lives — but about the aggregation logic that turns many independent states into one activation decision.

"""Tests for multi-board coordination and aggregation."""
from __future__ import annotations

import pytest


def _submission() -> CoordinatingSubmission:
    sub = CoordinatingSubmission(protocol_id="STUDY-2026-004")
    sub.add_board(BoardReview("central", BoardKind.CENTRAL, site_id=None))
    sub.add_board(BoardReview("site-014", BoardKind.LOCAL, site_id="014"))
    sub.add_board(BoardReview("site-022", BoardKind.LOCAL, site_id="022"))
    return sub


def _approve(sub: CoordinatingSubmission, board_id: str) -> None:
    sub.transition_board(board_id, BoardState.SUBMITTED, actor="coordinator", reason="submitted")
    sub.transition_board(board_id, BoardState.UNDER_REVIEW, actor="board_admin", reason="intake accepted")
    sub.transition_board(board_id, BoardState.APPROVED, actor="board_chair", reason="approved")


def test_not_fully_approved_until_every_required_board_approves() -> None:
    sub = _submission()
    _approve(sub, "central")
    _approve(sub, "site-014")
    assert sub.is_fully_approved() is False
    assert sub.blocked_sites() == ["022"]
    _approve(sub, "site-022")
    assert sub.is_fully_approved() is True
    assert sub.blocked_sites() == []


def test_illegal_transition_is_rejected_without_mutating_state() -> None:
    sub = _submission()
    with pytest.raises(IllegalTransitionError):
        sub.transition_board("site-014", BoardState.APPROVED, actor="someone", reason="skip review")
    assert sub.boards["site-014"].state is BoardState.NOT_SUBMITTED


def test_unknown_board_raises_key_error() -> None:
    sub = _submission()
    with pytest.raises(KeyError):
        sub.transition_board("site-099", BoardState.SUBMITTED, actor="coordinator", reason="n/a")

The first test is the one that actually protects a site from premature activation: it proves that a fully approved central board plus one approved local board still leaves the coordinator reporting the second site as blocked, and that the blocked list narrows only when that site’s own board reaches APPROVED. The second and third tests confirm that a rejected or misdirected transition can never quietly advance the aggregate.