Setting Up FSMA 204 Data Retention That Survives an FDA Audit
The task looks simple: schedule a job that keeps traceability records for two years and archives the rest. The failure it produces in production is not simple. The most damaging outcome of a naive retention setup is the silent archival of incomplete records — a payload with a timezone-naive creation_date or a missing traceability_lot_number slips past a permissive filter, lands in cold storage looking healthy, and only surfaces months later as a ValidationError the first time the FDA runs a Subpart S traceback against that partition. By then the record is unrecoverable and the audit is already failing.
This guide sets up a retention ingestion boundary that refuses to archive a record it cannot prove is complete and correctly timestamped. It intercepts malformed Key Data Elements (KDEs) before they enter the retention queue, normalizes every timestamp to UTC, and routes anything ambiguous to quarantine instead of deleting or blindly archiving it. It is the concrete, single-scheduler version of the lifecycle engine described in the parent Data Retention Policies guide.
Root Cause: Why Retention Setups Fail an Audit
The retention window under FSMA 204 (21 CFR Part 1, Subpart S) is computed per record, from the timestamp of the Critical Tracking Event it documents. That single fact is where most setups break, because the expiration math is only ever as trustworthy as the fields feeding it. Three compounding defects account for nearly every retention finding an auditor raises:
- Timezone-naive timestamps. A supplier feed delivers
creation_dateas"2024-03-15T08:30:00"with no offset. A naive scheduler computes the 730-day cutoff against server-local time, so the same record is “expired” in one region and “active” in another. Archival and purge become non-deterministic, and chronological reconstruction of a lot’s lineage silently reorders events. - Absent mandatory KDEs. A record missing
traceability_lot_numberortransformation_input_traceability_lot_numberpasses a filter that only checks for a non-null primary key. It archives cleanly, but the moment an FDA query joins on the lot code, the row cannot participate in the traceback and the query raises rather than returns. - Unhandled null states in the retention engine itself. A misconfigured TTL, an epoch-vs-ISO format mix, or a cutoff comparison against a
Nonetimestamp throws mid-batch. Whatever records had not yet been processed are left in an indeterminate state — some archived, some not — with no clean way to prove which.
Because these defects originate upstream, the correct place to stop them is the ingestion boundary, before a record is ever queued for retention. If the Supplier Data Ingestion feed hands the scheduler a naive datetime, the fix is to reject it at the door — not to guess an offset and archive it anyway.
Minimal Reproducible Example: The Silent-Archival Failure
The snippet below reproduces the failure in isolation. A permissive retention filter keeps anything whose creation_date looks recent and whose primary key is present. Two realistic supplier payloads defeat it: one carries a naive timestamp, the other is missing a mandatory KDE. Both are waved through to the archive, and the failure only appears when the audit query runs.
from datetime import datetime, timedelta
# Two payloads as they actually arrive from heterogeneous supplier feeds.
incoming = [
{ # naive timestamp: no UTC offset — the cutoff math is ambiguous
"record_id": "cte-9001",
"traceability_lot_number": "LOT-2024-03-15-AA1",
"creation_date": "2024-03-15T08:30:00",
"product_description": "romaine, chopped",
},
{ # missing traceability_lot_number — unusable in a Subpart S traceback
"record_id": "cte-9002",
"creation_date": "2024-03-15T08:31:00+00:00",
"product_description": "romaine, chopped",
},
]
def naive_retention_filter(rec: dict) -> bool:
# BUG 1: parses a naive string against server-local time.
# BUG 2: only checks record_id, never the mandatory KDEs.
created = datetime.fromisoformat(rec["creation_date"].replace("Z", ""))
return bool(rec.get("record_id")) and created > datetime.now() - timedelta(days=730)
archived = [r for r in incoming if naive_retention_filter(r)]
# Both records archive "successfully" here.
# ...months later, the FDA 24-hour traceback query runs:
for r in archived:
lot = r["traceability_lot_number"] # KeyError on cte-9002
print(f"tracing {lot} from {datetime.fromisoformat(r['creation_date'])}")
Running this archives both records, then raises KeyError: 'traceability_lot_number' on cte-9002 at query time — exactly the class of error that presents as a ValidationError during real Subpart S execution. The naive record (cte-9001) is worse: it does not raise, it just sorts into the wrong position in the event chain.
Fix Implementation: A Validation Boundary With a Circuit Breaker
The fix moves all trust to the ingestion boundary. A pydantic v2 model rejects any payload missing a mandatory KDE and coerces every creation_date to timezone-aware UTC — a naive value is refused, never silently localized. A CircuitBreaker halts the pipeline after a run of consecutive failures so a degraded upstream feed cannot flood cold storage with garbage. Nothing is ever dropped: non-compliant payloads are serialized to a quarantine directory with explicit failure metadata, preserving an immutable trail of ingestion anomalies.
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, ValidationError, field_validator
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)
logger = logging.getLogger("fsma204.retention_setup")
RETENTION_DAYS = 730 # FDA two-year floor; never lower this below the mandate.
class RetentionRecord(BaseModel):
"""The narrow contract the retention scheduler needs to trust a record."""
record_id: str
traceability_lot_number: str
product_description: str
creation_date: datetime
transformation_input_traceability_lot_number: Optional[str] = None
location_id: Optional[str] = None
@field_validator("creation_date", mode="before")
@classmethod
def coerce_utc(cls, v: Any) -> datetime:
# Accept epoch or ISO string, but REJECT a naive datetime rather than
# guess an offset — a wrong offset silently corrupts the retention clock.
if isinstance(v, (int, float)):
return datetime.fromtimestamp(v, tz=timezone.utc)
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
raise ValueError(f"unsupported creation_date type: {type(v)!r}")
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
raise ValueError("creation_date must be timezone-aware (UTC offset required)")
return dt.astimezone(timezone.utc)
@field_validator("traceability_lot_number")
@classmethod
def lot_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("traceability_lot_number cannot be blank")
return v.strip()
@dataclass
class CircuitBreaker:
failure_threshold: int = 5
consecutive_failures: int = 0
is_open: bool = False
def record_failure(self) -> None:
self.consecutive_failures += 1
if self.consecutive_failures >= self.failure_threshold:
self.is_open = True
logger.critical("Circuit breaker OPEN: consecutive validation failures exceeded threshold.")
def record_success(self) -> None:
if self.is_open:
logger.info("Circuit breaker CLOSED: pipeline stability restored.")
self.consecutive_failures = 0
self.is_open = False
class RetentionIngestBoundary:
def __init__(self, quarantine_dir: Path = Path("/var/log/fsma204/quarantine")) -> None:
self.quarantine_dir = quarantine_dir
self.quarantine_dir.mkdir(parents=True, exist_ok=True)
self.breaker = CircuitBreaker()
def _quarantine(self, payload: dict[str, Any], reason: str) -> None:
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f")
path = self.quarantine_dir / f"quarantined_{ts}.json"
path.write_text(json.dumps(
{"original_payload": payload, "failure_reason": reason, "quarantined_at": ts},
indent=2,
))
logger.info("Record quarantined | reason=%s | file=%s", reason, path)
def accept(self, payload: dict[str, Any]) -> Optional[RetentionRecord]:
"""Validate one payload at the boundary. Returns a queue-ready record or None."""
if self.breaker.is_open:
logger.error("Pipeline halted: circuit breaker active. Manual intervention required.")
return None
try:
record = RetentionRecord(**payload)
except ValidationError as e:
self.breaker.record_failure()
reason = "; ".join(f"{err['loc'][0]}:{err['type']}" for err in e.errors())
self._quarantine(payload, f"schema_invalid ({reason})")
return None
# A record already older than the window should have been archived upstream;
# its arrival here signals a replay or a misconfigured scheduler, not a purge trigger.
cutoff = datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)
if record.creation_date < cutoff:
self._quarantine(payload, "outside_active_retention_window")
self.breaker.record_success()
return None
self.breaker.record_success()
logger.info(
"Record accepted to retention queue | lot=%s | created_utc=%s",
record.traceability_lot_number, record.creation_date.isoformat(),
)
return record
Figure — KDE payload validation and fallback flow:
Three decisions in this code are load-bearing for the audit. The mode="before" validator refuses a naive creation_date instead of localizing it, which is what keeps the retention clock deterministic across regions. The mandatory-field contract is enforced by pydantic’s required-field mechanics, so a missing traceability_lot_number fails validation before archival rather than at query time. And the quarantine path fails closed — an ambiguous record is preserved with its reason, never dropped and never archived on a guess.
Verification Steps
Confirm the boundary works before you point it at production storage. Feed it the two payloads from the reproduction and one clean record, then check three independent signals.
1. Log output. A correctly configured boundary emits one acceptance and two quarantines:
2026-07-02T09:14:02+0000 | INFO | fsma204.retention_setup | Record quarantined | reason=schema_invalid (creation_date:value_error) | file=/var/log/fsma204/quarantine/quarantined_20260702T091402...json
2026-07-02T09:14:02+0000 | INFO | fsma204.retention_setup | Record quarantined | reason=schema_invalid (traceability_lot_number:missing) | file=...
2026-07-02T09:14:02+0000 | INFO | fsma204.retention_setup | Record accepted to retention queue | lot=LOT-2024-06-01-BB2 | created_utc=2024-06-01T12:00:00+00:00
2. Unit assertions. Encode the invariants so a regression cannot ship silently:
def test_naive_timestamp_is_rejected() -> None:
boundary = RetentionIngestBoundary(quarantine_dir=tmp_path)
result = boundary.accept({
"record_id": "cte-9001",
"traceability_lot_number": "LOT-2024-03-15-AA1",
"product_description": "romaine, chopped",
"creation_date": "2024-03-15T08:30:00", # no offset
})
assert result is None # never queued
assert list(tmp_path.glob("quarantined_*.json")) # trail preserved
def test_missing_kde_never_queues() -> None:
boundary = RetentionIngestBoundary(quarantine_dir=tmp_path)
assert boundary.accept({
"record_id": "cte-9002",
"creation_date": "2024-03-15T08:31:00+00:00",
}) is None
3. SQL state check. After a real run, no archived record may sit inside the window without a timezone-aware timestamp. This query must return zero rows:
SELECT record_id, creation_date
FROM fsma204_retention_archive
WHERE creation_date > (now() AT TIME ZONE 'UTC') - INTERVAL '730 days'
AND (creation_date IS NULL OR traceability_lot_number IS NULL);
Any row returned is a record that reached the archive without a valid clock or lot code — the exact defect this setup exists to prevent.
Related Edge Cases to Check Next
- Daylight-saving drift from legacy
pytz. If any upstream stage still localizes withpytzrather than the standard-libraryzoneinfo, historical timestamps near a DST boundary can shift by an hour and land a record in the wrong retention week. Normalize withzoneinfoend to end. - Lot splits that drop the retention anchor. When a lot is split or transformed, the child records must inherit the parent’s
creation_dateas their retention anchor. A split that re-stamps the child with the transformation time resets the two-year clock and can hold a record past its lawful window — verify against the KDE Field Mapping Guide propagation rules. - Replayed payloads outside the window. A retry storm from a flaky feed can re-deliver already-archived records. The boundary quarantines these as
outside_active_retention_window, but you should also confirm the delivery layer is backing off correctly — the discipline in API Polling Strategies keeps a transient fault from being mistaken for a stream of retention failures.
Related
- Data Retention Policies — the full KDE lifecycle engine this setup feeds into
- KDE Field Mapping Guide — the field contract that makes the retention clock trustworthy
- Fallback Routing Logic — where quarantined records surface for operator reconciliation
- Security Boundaries for Trace Data — access controls and audit logging that gate every archival action
- Compliance Checklists & Readiness — the end-to-end audit-readiness assessment this setup supports
- FSMA 204 Food Traceability Rule — the FDA’s definitive regulatory baseline
Up: Data Retention Policies — this how-to configures the ingestion boundary in front of that cluster’s retention lifecycle engine.