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.
Prerequisites
- Python 3.11 or newer.
- Dependencies: the standard library only for the enforcement logic:
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.
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.
_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:
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.
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.
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:
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.
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:
# 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 |
Related
- Automated batch invalidation & quarantine workflows — the FSM these guards protect.
- Implementing a quarantine state machine in Python — the transition table and audit chain.
- Implementing 21 CFR Part 11 electronic signatures — the signature a rejection or release requires.
- CAPA routing automation for temperature excursions — the event that opens the reversible hold.
- QMS integration, audit trails & automated batch disposition — the wider disposition boundary.
For architectural context, see automated batch invalidation & quarantine workflows, part of the broader QMS integration, audit trails & automated batch disposition section.