Reversible vs Irreversible Batch Holds Under EU GDP

Not every batch hold is the same kind of decision, and conflating the two is one of the more damaging mistakes a cold chain quality system can make. A precautionary quarantine pending assessment can be lifted; a rejection after a failed disposition cannot. If the code treats both as the same “on hold” flag, product can be silently un-rejected, or a reversible hold can be treated as final and sound stock scrapped. This guide draws the line precisely and shows how to enforce it in Python, so the workflow itself makes an irreversible hold irreversible. It builds directly on the quarantine state machine and the framing in automated batch invalidation and quarantine workflows.

Regulatory hook

EU GDP §5.5 requires medicinal products that have been outside their authorized storage conditions to be segregated from saleable stock and assessed before any decision about their fate. That assessment can end two ways: the product is judged still within specification and returned to stock, or it is judged compromised and rejected. The first outcome means the hold was reversible — a temporary segregation. The second is a disposition of last resort that is irreversible: a rejected batch is removed from the supply chain permanently. 21 CFR Part 11 §11.50 requires the disposition that ends the hold to be signed with the signer’s identity, timestamp, and the meaning of the action, and §11.10(e) requires the whole sequence to be a tamper-evident, non-obscuring record.

The comparison below is the distinction the rest of this guide encodes.

Reversible precautionary hold versus irreversible rejection under EU GDP §5.5 A two-column comparison matrix contrasting reversible holds and irreversible rejections across states covered, exit path, signature requirement, automation, and audit behaviour. Reversible hold precautionary segregation Irreversible rejection terminal condemnation States covered QUARANTINE · UNDER_REVIEW REJECTED Exit transition may return to IN_STOCK none — no successor Signature actor + reason §11.50 signed disposition Automation system may apply human only Audit on entry hash-linked record hash-linked record GDP basis §5.5 segregate & assess §5.5 remove from supply

Prerequisites

  • Python 3.11 or newer.
  • Dependencies: the standard library only for the enforcement logic:
bash
python3.11 -m venv .venv && source .venv/bin/activate
python -m pip install --upgrade pip
  • Assumed upstream: the quarantine finite state machine and its transition table from implementing a quarantine state machine in Python.
  • Access control: an irreversible rejection may only be driven by an authenticated qualified person; the automation identity is explicitly forbidden from that transition.

Step-by-Step Implementation

Step 1 — Classify each state by reversibility

Make reversibility an explicit property of the destination state, not a comment. This single source of truth drives every downstream guard.

python
from __future__ import annotations

from enum import Enum


class BatchState(str, Enum):
    IN_STOCK = "IN_STOCK"
    QUARANTINE = "QUARANTINE"
    UNDER_REVIEW = "UNDER_REVIEW"
    RELEASED = "RELEASED"
    REJECTED = "REJECTED"


# EU GDP §5.5: a reversible hold is a segregation that may still be lifted.
_REVERSIBLE = frozenset({BatchState.QUARANTINE, BatchState.UNDER_REVIEW})
# Terminal states cannot be exited; REJECTED is the irreversible disposition.
_TERMINAL = frozenset({BatchState.RELEASED, BatchState.REJECTED})


def is_reversible(state: BatchState) -> bool:
    return state in _REVERSIBLE

Step 2 — Forbid automation from applying an irreversible hold

A reversible quarantine is safety-positive and may be applied by the system; a rejection is a human judgment. Encode that asymmetry as a guard keyed on the actor.

python
_AUTOMATION_ACTORS = frozenset({"system:disposition", "system:capa-router"})


def assert_actor_may_reject(actor: str, to_state: BatchState) -> None:
    # EU GDP §5.5 + §11.50: only a named person condemns a batch, never a job.
    if to_state is BatchState.REJECTED and actor in _AUTOMATION_ACTORS:
        raise PermissionError("REJECTED is a signed human disposition, not automatable")

Prove the automation identity cannot reject:

python
try:
    assert_actor_may_reject("system:disposition", BatchState.REJECTED)
    raise AssertionError("automation must not reject a batch")
except PermissionError:
    pass

Step 3 — Require a signature only for the irreversible disposition

Both terminal states are dispositions and need signing, but the enforcement point is where reversibility ends. Require a signature reference on any move into a terminal state.

python
from typing import Optional


def assert_disposition_signed(to_state: BatchState, signature_ref: Optional[str]) -> None:
    # §11.50: entering a terminal disposition state requires a signed manifestation.
    if to_state in _TERMINAL and not signature_ref:
        raise ValueError(f"{to_state.value} requires a §11.50 signature reference")

Step 4 — Block any exit from a terminal state

Irreversibility means the terminal state has no successor. Enforce it independently of the transition table so an accidental table edit cannot re-open a rejected batch.

python
def assert_not_leaving_terminal(current: BatchState) -> None:
    # EU GDP §5.5: a rejected batch is removed from the supply chain permanently.
    if current in _TERMINAL:
        raise PermissionError(f"{current.value} is terminal; no transition permitted")

Confirm a rejected batch cannot move:

python
try:
    assert_not_leaving_terminal(BatchState.REJECTED)
    raise AssertionError("must not leave REJECTED")
except PermissionError:
    pass

Keeping this guard separate from the transition table is deliberate. The table in the quarantine state machine already omits any edge out of REJECTED, so in normal operation this check is redundant — and that redundancy is the point. Irreversibility is too important to depend on a single data structure that a well-meaning edit could widen; a defense-in-depth guard that consults the _TERMINAL set independently means an accidental table change cannot, on its own, make a rejected batch recoverable. Compliance-critical invariants are worth enforcing twice.

Step 5 — Compose the guards into one disposition check

Wrap the individual guards so every disposition transition runs the full set in order. This is the function the workflow calls before mutating state.

python
def check_disposition(
    current: BatchState,
    to_state: BatchState,
    actor: str,
    signature_ref: Optional[str],
) -> None:
    assert_not_leaving_terminal(current)              # no exit from terminal
    assert_actor_may_reject(actor, to_state)          # human-only rejection
    assert_disposition_signed(to_state, signature_ref)  # §11.50 signature

Verify a lawful signed rejection by a qualified person passes, while the same move by automation or without a signature fails:

python
# A qualified person with a signature may reject.
check_disposition(BatchState.UNDER_REVIEW, BatchState.REJECTED,
                  actor="qp@site", signature_ref="SIG-88")

for bad in (
    lambda: check_disposition(BatchState.UNDER_REVIEW, BatchState.REJECTED,
                              "system:disposition", "SIG-88"),   # automation
    lambda: check_disposition(BatchState.UNDER_REVIEW, BatchState.REJECTED,
                              "qp@site", None),                  # unsigned
    lambda: check_disposition(BatchState.REJECTED, BatchState.IN_STOCK,
                              "qp@site", "SIG-90"),              # leaving terminal
):
    try:
        bad()
        raise AssertionError("guard should have rejected this move")
    except (PermissionError, ValueError):
        pass

Compliance Validation Checklist

Run this as part of computerized-system validation; each item is independently confirmable by an auditor.

Troubleshooting

Symptom Root cause Fix
Rejected batch reappears in saleable stock Terminal exit not blocked Enforce assert_not_leaving_terminal independent of the table
System auto-rejected a batch overnight Automation actor allowed to reject Add the job identity to _AUTOMATION_ACTORS and guard on it
Release/reject recorded with no signer Signature guard skipped Require signature_ref for all _TERMINAL entries
Quarantine lift has no justification Reversible move not audited Require actor and reason on the return to IN_STOCK
Team wants to “undo” a rejection Treating terminal as mutable Open a new deviation investigation; keep the original record intact

For architectural context, see automated batch invalidation & quarantine workflows, part of the broader QMS integration, audit trails & automated batch disposition section.