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 QUARANTINE straight to SHIPPED without 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.

Cold chain batch disposition finite state machine States IN_STOCK, QUARANTINE, UNDER_REVIEW, RELEASED, and REJECTED connected by guarded transitions; reversible edges are solid, the irreversible rejection edge is emphasized, and each transition writes an audit record. BATCH DISPOSITION STATE MACHINE · EU GDP §5.5 confirmed excursion QP opens assessment signed release excursion invalidated signed rejection (irreversible) IN_STOCK saleable QUARANTINE reversible hold UNDER_REVIEW QP assessment RELEASED saleable · signed REJECTED terminal · irreversible every transition → audit chain (§11.10(e))

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.

python
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 example QUARANTINE → RELEASED directly must fail.
  • Signature-guard tests. Assert RELEASED and REJECTED raise without a signature_ref and 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_hash no longer matches the successor’s prev_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.
python
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).

For architectural context, this page sits under QMS integration, audit trails & automated batch disposition.