Automated Batch Invalidation & Quarantine Workflows
The moment an excursion is confirmed against a batch, that batch must stop moving. It cannot be picked, shipped, or sold until a qualified person has assessed the thermal exposure against the product’s stability data and recorded a decision. Doing this by hand — a phone call to the warehouse, a manual status flag, a sticky note on a pallet — is exactly the gap that lets compromised product reach a patient or lets sound product be scrapped without evidence. Automated batch invalidation replaces that gap with a finite state machine (FSM) that transitions a batch into quarantine on a confirmed excursion, permits only the legal transitions from each state, requires a reason and an actor on every move, and appends each transition to a tamper-evident audit trail. The regulatory anchor is direct: EU GDP §5.5 requires product that has been outside its authorized storage conditions to be segregated and assessed before any return to saleable stock, EU GDP §9 requires those conditions to be preserved and provenanced through transport, and 21 CFR Part 11 makes each status change a signed, audited electronic record.
Problem Statement: Status Is a Controlled Transition, Not a Field
Three concrete problems drive teams to model batch status as an FSM rather than a mutable field.
- Illegal transitions must be impossible, not merely discouraged. A batch cannot go from
QUARANTINEstraight toSHIPPEDwithout a signed disposition. If status is a free-text field, nothing prevents that jump; an FSM makes it unrepresentable, which is what EU GDP §5.5 segregation actually requires. - Every move needs a who, a why, and a when. A status that changed with no attributable actor, recorded reason, and contemporaneous timestamp is not a 21 CFR Part 11 §11.10(e) record. The workflow must refuse a transition that lacks them.
- Reversibility is a legal distinction, not an implementation detail. A precautionary hold pending assessment can be lifted; a rejection after a failed disposition cannot. Conflating the two lets product be un-rejected, which no inspector will accept.
This workflow sits inside the QMS integration, audit trails and automated batch disposition boundary. It is triggered when CAPA routing automation for temperature excursions opens a CAPA against a batch, and its two deep-dives cover implementing a quarantine state machine in Python and the disposition-law distinction in reversible vs irreversible batch holds under EU GDP. The signed decision that lifts or ends a hold is produced by implementing 21 CFR Part 11 electronic signatures.
The scope of a hold is a design decision that carries as much regulatory weight as the hold itself. When a single excursion sensor reports against a cold room that stores twelve lots, does the workflow quarantine the one pallet the deviation is provably attributable to, or every lot in the affected zone? EU GDP §5.5 does not prescribe the granularity, but it does require the segregation to be justified, so the workflow must record the containment rationale on the transition — the mapping from the sensor’s coverage to the set of affected batch_id values is itself evidence a reviewer will examine. Over-quarantining is operationally expensive but defensible; under-quarantining, where a compromised lot escapes the hold because the coverage map was wrong, is the failure that reaches a patient. The safe default is to hold the full attributable set and let the qualified-person assessment narrow it, because narrowing a hold is a documented, signed decision while widening one after product has shipped is a recall.
Concept & Specification: States, Guards, and the Transition Record
The workflow has five states. IN_STOCK is saleable; QUARANTINE is a reversible precautionary hold; UNDER_REVIEW is an active qualified-person assessment; RELEASED returns the batch to saleable stock via a signed disposition; and REJECTED is a terminal, irreversible condemnation. Each transition is guarded — permitted only from specific source states and only when its precondition holds — and each produces a record with the fields below. The Regulatory anchor column states why each field is mandatory.
| Field | Type | Constraint | Regulatory anchor |
|---|---|---|---|
batch_id |
string | Immutable product lot identifier | EU GDP §5.5 segregation subject |
from_state |
enum | One of the five states | §11.10(e) prior-value evidence |
to_state |
enum | Legal successor of from_state |
EU GDP §5.5 controlled disposition |
reversible |
boolean | Derived from to_state |
EU GDP §5.5 hold-type distinction |
actor |
string | Authenticated identity | §11.10(d) authority, §11.10(e) attributable |
reason |
string | Non-empty justification | §11.10(e) reason for change |
signature_ref |
string | null | Required for RELEASED / REJECTED |
§11.50 signed disposition |
occurred_at |
string (ISO 8601, UTC) | Timezone-aware, UTC | §11.10(e) contemporaneous stamp |
prev_hash |
string (sha256) | Links to prior transition | §11.10(e) tamper-evident chain |
record_hash |
string (sha256) | SHA-256 of the canonical record | ALCOA+ enduring, tamper-evident |
The signature_ref guard is what separates a precautionary hold from a disposition. A transition into QUARANTINE needs an actor and a reason but no signature, because holding product is safety-positive and reversible. A transition into RELEASED or REJECTED is a disposition and must carry a §11.50 signature reference, because returning product to stock or condemning it is a decision a named person is accountable for.
The from_state field earns its place by making the audit trail non-obscuring in the sense §11.10(e) intends: because every record preserves the prior value alongside the new one, an inspector reading the chain can reconstruct the batch’s entire status history without consulting any external log. This is why a status field — which holds only the current value — is insufficient evidence even when it is backed by database change-data-capture: the regulated record must carry the transition, not just the endpoint. The reversible flag is derived rather than supplied so it can never contradict the to_state; storing it explicitly, in addition to the transition, spares a downstream reviewer from re-deriving the hold’s legal character and lets a query filter reversible holds from terminal dispositions directly.
Architecture Diagram: The Batch Disposition State Machine
The diagram is the state machine itself: nodes are batch states, edges are guarded transitions labeled with the condition that permits them, and the two terminal-versus-reversible paths are visually distinct. Every edge, when taken, appends one record to the audit chain.
Production Python Implementation
The module is a complete quarantine FSM. Legal transitions are declared as data; a guard rejects any illegal move and any disposition lacking a signature; and every accepted transition appends a hash-linked record. The clock and persistence are injected for deterministic validation. Each block cites the clause it satisfies.
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Optional
class BatchState(str, Enum):
IN_STOCK = "IN_STOCK"
QUARANTINE = "QUARANTINE"
UNDER_REVIEW = "UNDER_REVIEW"
RELEASED = "RELEASED"
REJECTED = "REJECTED"
# Legal transitions as controlled data. Anything not listed is unrepresentable,
# which is how EU GDP §5.5 segregation is enforced structurally, not by policy.
_LEGAL: dict[BatchState, frozenset[BatchState]] = {
BatchState.IN_STOCK: frozenset({BatchState.QUARANTINE}),
BatchState.QUARANTINE: frozenset({BatchState.UNDER_REVIEW, BatchState.IN_STOCK}),
BatchState.UNDER_REVIEW: frozenset({BatchState.RELEASED, BatchState.REJECTED}),
BatchState.RELEASED: frozenset(), # terminal (saleable)
BatchState.REJECTED: frozenset(), # terminal (irreversible)
}
# Transitions that constitute a disposition and therefore require a §11.50 signature.
_REQUIRES_SIGNATURE: frozenset[BatchState] = frozenset(
{BatchState.RELEASED, BatchState.REJECTED}
)
# Reversible destination states (a hold that can still be lifted).
_REVERSIBLE_TO: frozenset[BatchState] = frozenset(
{BatchState.QUARANTINE, BatchState.UNDER_REVIEW, BatchState.IN_STOCK}
)
class IllegalTransition(ValueError):
"""Raised when a move is not permitted from the current state."""
@dataclass
class BatchWorkflow:
batch_id: str
state: BatchState = BatchState.IN_STOCK
_prev_hash: str = "0" * 64
persist: Callable[[dict], None] = lambda r: None
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc)
def transition(
self,
to_state: BatchState,
actor: str,
reason: str,
signature_ref: Optional[str] = None,
) -> dict:
# Guard 1: only legal successors are permitted (EU GDP §5.5).
if to_state not in _LEGAL[self.state]:
raise IllegalTransition(f"{self.state.value} -> {to_state.value} not permitted")
# Guard 2: attribution and reason are mandatory (§11.10(e)).
if not actor or not reason:
raise ValueError("actor and reason are required for every transition")
# Guard 3: a disposition must carry a signature reference (§11.50).
if to_state in _REQUIRES_SIGNATURE and not signature_ref:
raise ValueError(f"{to_state.value} requires a §11.50 signature reference")
record = {
"batch_id": self.batch_id,
"from_state": self.state.value,
"to_state": to_state.value,
"reversible": to_state in _REVERSIBLE_TO,
"actor": actor, # §11.10(d)/(e) attributable
"reason": reason, # §11.10(e) reason for change
"signature_ref": signature_ref, # §11.50 disposition signature
"occurred_at": self.clock().isoformat(), # §11.10(e) contemporaneous
"prev_hash": self._prev_hash,
}
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"))
# §11.10(e): tamper-evident link — a later edit breaks the chain.
record["record_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
self._prev_hash = record["record_hash"]
self.state = to_state
self.persist(record) # durable BEFORE the caller proceeds
return record
# Convenience entry point for the disposition orchestrator.
def quarantine_on_excursion(
wf: BatchWorkflow, capa_id: str, actor: str = "system:disposition"
) -> dict:
# EU GDP §5.5: a confirmed excursion segregates the batch immediately.
return wf.transition(BatchState.QUARANTINE, actor=actor,
reason=f"confirmed excursion, CAPA {capa_id}")
The full transition table, an idempotent replay guard, and the persistence adapter are developed in implementing a quarantine state machine in Python; the legal and regulatory basis for which destinations may be reversed and which are terminal is examined in reversible vs irreversible batch holds under EU GDP.
Configuration & Deployment
The transition table, the signature-required set, and the reversible set are controlled data under your ICH Q10 quality system. Any edit is a change-management event that may require re-validation, because it alters what dispositions the system will permit.
| Variable | Example | Purpose | Regulatory anchor |
|---|---|---|---|
BATCH_TRANSITION_TABLE |
JSON of legal edges | Structural segregation rules | EU GDP §5.5 controlled disposition |
BATCH_SIGNATURE_STATES |
RELEASED,REJECTED |
Which moves need a signature | §11.50 signed disposition |
BATCH_AUDIT_DSN |
append-only store DSN | Hash-chained transition sink | §11.10(e) secure audit trail |
BATCH_ACTOR_DIRECTORY |
identity provider endpoint | Authenticate the transition actor | §11.10(d) authority |
Deploy the workflow so batch state changes are the only sanctioned path to alter saleability — the warehouse and order-management systems read batch state, they never write it directly. Rotate the identity provider and audit-store credentials on a fixed schedule and fail closed. Pin the active transition-table fingerprint in the deployment manifest so an unapproved edge cannot silently reach production.
Verification & Testing
The workflow gates saleability of regulated product, so its tests are validation evidence. Build the suite around the moves an inspector will probe.
- Legal-transition tests. Assert every edge in the table is accepted and, crucially, that every edge not in the table raises
IllegalTransition— for exampleQUARANTINE → RELEASEDdirectly must fail. - Signature-guard tests. Assert
RELEASEDandREJECTEDraise without asignature_refand succeed with one, demonstrating the §11.50 disposition gate. - Terminal-state tests. Assert no transition is permitted out of
REJECTED, proving irreversibility under EU GDP §5.5. - Attribution tests. Assert a transition with an empty actor or reason raises, so no move can be anonymous or unjustified (§11.10(e)).
- Audit-chain tests. Mutate one stored transition field and assert the recomputed
record_hashno longer matches the successor’sprev_hash. - CSV protocol hook. Expose a fixture loader that reads an OQ test-vector CSV (from_state, to_state, signature present, expected result) so Operational Qualification runs against documented expected outputs.
from datetime import datetime, timezone
def _clock():
return datetime(2026, 7, 14, 6, 0, tzinfo=timezone.utc)
def test_illegal_direct_release_is_blocked():
wf = BatchWorkflow("BATCH-1", state=BatchState.QUARANTINE, clock=_clock)
try:
wf.transition(BatchState.RELEASED, actor="qp@site",
reason="skip review", signature_ref="SIG-1")
assert False, "QUARANTINE -> RELEASED must be illegal (EU GDP §5.5)"
except IllegalTransition:
pass
def test_disposition_requires_signature():
wf = BatchWorkflow("BATCH-2", state=BatchState.UNDER_REVIEW, clock=_clock)
try:
wf.transition(BatchState.REJECTED, actor="qp@site", reason="failed stability")
assert False, "REJECTED requires a §11.50 signature reference"
except ValueError:
pass
Known Failure Modes & Mitigations
| Failure mode | Symptom | Mitigation | Regulatory anchor |
|---|---|---|---|
| Direct write to batch status | Saleability changed off-workflow | Make state changes the only sanctioned mutation path | EU GDP §5.5 controlled disposition |
| Disposition without signature | Released/rejected with no signer | Signature guard on terminal-ish states | §11.50 signed disposition |
| Un-rejecting a batch | Terminal state exited | Empty successor set for REJECTED |
EU GDP §5.5 irreversibility |
| Duplicate quarantine on re-delivery | Two holds for one excursion | Idempotent transition keyed on event id | ALCOA+ consistent records |
| Anonymous transition | Move with no actor/reason | Reject empty actor or reason | §11.10(e) attributable |
| Clock skew on transition | Non-monotonic audit ordering | Inject disciplined UTC clock; reject backward stamps | §11.10(e) contemporaneous |
When an assessment stalls in UNDER_REVIEW past its target interval, escalate the review ownership rather than auto-deciding — a batch may sit safely in a reversible hold indefinitely, but it must never be auto-released or auto-rejected without a signed human decision.
Compliance Q&A
Can an automated workflow release a quarantined batch back to saleable stock?
No. Under EU GDP §5.5 a batch that has been outside its authorized conditions must be assessed before any return to saleable stock, and under 21 CFR Part 11 §11.50 that release is a disposition a named qualified person signs. The workflow automates the precautionary hold, which is safety-positive and reversible, but a transition into RELEASED is guarded to require a signature reference — the system cannot make that decision on its own.
Why model batch status as a state machine instead of a status field?
Because EU GDP §5.5 requires that certain transitions be impossible, not merely discouraged. A free-text or enumerated status field permits any value to follow any other, so nothing structurally prevents a batch jumping from QUARANTINE to SHIPPED without a signed disposition. A finite state machine encodes only the legal edges, making an illegal disposition unrepresentable and giving an inspector a provable segregation control.
Is a rejected batch ever recoverable if the rejection was a mistake?
The REJECTED state is terminal and irreversible by design, so the workflow will not transition out of it. A rejection made in error is handled as a new, separately documented quality event — a deviation investigation that records the error and its correction — never by silently un-rejecting the original batch. Preserving the terminal record intact is what keeps the audit trail non-obscuring under §11.10(e).
Related
- QMS integration, audit trails & automated batch disposition — the wider disposition boundary this workflow serves.
- Implementing a quarantine state machine in Python — the full FSM with idempotent replay and persistence.
- Reversible vs irreversible batch holds under EU GDP — the legal basis for terminal versus liftable holds.
- CAPA routing automation for temperature excursions — the trigger that opens a CAPA and quarantines the batch.
- Implementing 21 CFR Part 11 electronic signatures — the signature that authorizes a disposition.
For architectural context, this page sits under QMS integration, audit trails & automated batch disposition.