Enforcing FERPA Retention Schedules in Patron Records

This page answers one narrow operational question: why do FERPA-governed student patron records — and the circulation trails attached to them — sit fully populated years after the student has left, and how do you enforce automated purging so that no record outlives the retention window your policy already defines? It sits under Patron Consent and Right-to-Erasure Management, which covers the consent and erasure lifecycle end to end, and within the broader Patron Validation & Privacy Data Routing architecture. If you run an academic or K-12 library whose borrower accounts are provisioned from a campus Student Information System (SIS), the graveyard of never-purged records below is one you will eventually discover during an audit.

Retention enforcer computing a purge-due date and branching to purge or keep A patron record carrying a separation date enters a retention enforcer. The enforcer reads the governing FERPA retention schedule, adds the retention window to the separation date to compute a purge-due date, and evaluates whether that date is now past. If the record is due, it branches to a purge-or-anonymize action that clears the student record and its circulation trail; if it is not yet due, it branches to a keep action that leaves the record untouched until a later run. Both branches write an entry to an append-only audit log. patron record + separation date + circulation trail retention schedule window per record class retention enforcer separation + window → purge-due date due? now ≥ due purge / anonymize clear record + circulation trail keep re-check next run yes no append-only audit log every PURGE and KEEP decision recorded with the computed due date — provable at audit time

Problem Framing

The symptom is not a crash. Circulation runs fine, patron lookups return correct results, and no exception fires. The problem is what is still there: student accounts and their full checkout history for people who graduated or withdrew years ago, long past the window your retention policy says they should have been destroyed. Nobody notices until an auditor, a records-retention review, or a subpoena asks the library to prove it purges on schedule — and a single query shows it does not.

The evidence is a count of records whose separation date is older than the retention window, still fully populated:

text
$ psql -c "SELECT patron_class, count(*) AS overdue \
           FROM patron_record r \
           JOIN patron_separation s USING (patron_id) \
           WHERE r.purged_at IS NULL \
             AND s.separation_date < now() - interval '5 years' \
           GROUP BY patron_class"
 patron_class | overdue
--------------+---------
 student      |    4127   <-- past a 5-year window, never purged
 alumni       |      88
(2 rows)

Four thousand student records, each with an intact circulation trail, sitting years past the point where FERPA-aligned policy required their destruction. The purged_at column is null on every one of them because nothing ever set it. The separation dates arrived faithfully from the SIS; no process ever acted on them.

Root Cause

The failure is that retention was written as policy but never encoded as a job. The rule — “destroy student patron records and their circulation history N years after separation” — lives in a records-retention schedule document, a PDF in a shared drive, or a line in a data-governance policy. It does not live anywhere in code. No scheduled task reads a separation date, adds the window, and compares the result to today.

Two contributing conditions make the gap invisible. First, the SIS does deliver separation events — a separation_date when a student graduates or withdraws — but the patron pipeline stores that date and moves on; the date is data nobody consumes. Second, retention is a deletion obligation, and deletion is the one operation nobody’s dashboard rewards. A missing record raises an alarm; a record that should be missing but is present raises none. So the pile grows silently, one term at a time, until an external review forces a reckoning.

The fix is to make the schedule executable: turn the retention window into a typed policy object, compute each record’s purge-due date deterministically from its separation date, and run an enforcer on a schedule that acts on every record whose due date has passed. This is the same disposition-per-record discipline the parent Patron Consent and Right-to-Erasure Management cluster applies to on-demand erasure requests — here it runs on a clock rather than on a request.

Solution

Encode the retention schedule as a typed policy keyed by patron class, compute the purge-due date as separation_date + window, and run an enforcer that purges or anonymizes any record at or past its due date and records the action. The enforcer is idempotent — a record already purged is skipped, so re-running the job is safe — and it fails per record rather than aborting the batch, so one problematic row cannot block the rest.

python
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Protocol

logger = logging.getLogger("ferpa.retention")


class RetentionError(Exception):
    """Raised when a record cannot be evaluated or purged safely."""


@dataclass(frozen=True)
class RetentionPolicy:
    """A governing schedule: how long a patron class is retained past separation."""

    patron_class: str
    window: timedelta

    def purge_due(self, separation: date) -> date:
        """The date on or after which this record must be purged."""
        return separation + self.window


# The retention schedule, expressed in code rather than in a policy document.
RETENTION_SCHEDULE: dict[str, RetentionPolicy] = {
    "student": RetentionPolicy("student", timedelta(days=5 * 365)),
    "alumni": RetentionPolicy("alumni", timedelta(days=7 * 365)),
}


@dataclass(frozen=True)
class PatronRecord:
    patron_id: str
    patron_class: str
    separation_date: date | None
    purged_at: date | None


class RetentionStore(Protocol):
    def due_records(self, as_of: date) -> list[PatronRecord]: ...
    def anonymize(self, patron_id: str) -> None: ...
    def record_audit(self, patron_id: str, action: str, due: date) -> None: ...


def enforce_retention(store: RetentionStore, *, as_of: date | None = None) -> int:
    """Purge every record whose FERPA retention window has closed.

    Returns the number of records purged. Idempotent: already-purged records
    are skipped, so the job is safe to re-run. A failure on one record is
    logged and quarantined without aborting the batch.
    """
    as_of = as_of or date.today()
    purged = 0

    for record in store.due_records(as_of):
        if record.purged_at is not None:
            continue  # idempotent: already handled on an earlier run
        if record.separation_date is None:
            # No separation date means the clock has not started; keep and log.
            logger.info(
                "retention_no_separation",
                extra={"patron_id": record.patron_id, "action": "keep"},
            )
            continue

        policy = RETENTION_SCHEDULE.get(record.patron_class)
        if policy is None:
            raise RetentionError(f"no policy for class {record.patron_class!r}")

        due = policy.purge_due(record.separation_date)
        if as_of < due:
            continue  # not yet due; re-checked on the next run

        try:
            store.anonymize(record.patron_id)
        except Exception as exc:  # noqa: BLE001 - isolate per-record failure
            logger.error(
                "retention_purge_failed",
                extra={"patron_id": record.patron_id, "due": due.isoformat()},
            )
            raise RetentionError(record.patron_id) from exc

        store.record_audit(record.patron_id, action="purge", due=due)
        logger.info(
            "retention_purged",
            extra={
                "patron_id": record.patron_id,
                "patron_class": record.patron_class,
                "due": due.isoformat(),
                "action": "purge",
            },
        )
        purged += 1

    return purged

The behavioural change is that the schedule now executes. Before, a separation_date sat in the row inert; after, purge_due turns it into a concrete deadline and the enforcer acts the moment as_of reaches it. anonymize is deliberately named over delete because the circulation trail usually cannot be dropped outright — aggregate collection statistics depend on it — so the operation strips the identifying columns and severs the link to the student while leaving an anonymized event for reporting. The audit write is not optional: every purge produces a durable record of what was destroyed and which due date justified it, which is exactly what an auditor asks for. Scheduling the enforcer itself — running it nightly under a worker — is covered in Automating Retention Schedule Enforcement with Celery Beat.

Compliance or Privacy Impact

Automating the enforcer changes the retention posture in three concrete ways, and each has a downstream effect worth tracking.

The enforcer narrows the retention surface rather than widening it, but it shifts risk onto the correctness of RETENTION_SCHEDULE and the separation dates feeding it. A window mistakenly set too long keeps records that should be gone; a window set too short destroys records prematurely and irreversibly. Changes to the schedule map deserve code review and the verification below.

Verification

Confirm the enforcer purges exactly the records at or past their due date, leaves the rest untouched, and is safe to re-run.

python
from datetime import date, timedelta


class FakeStore:
    def __init__(self, records: list[PatronRecord]) -> None:
        self._records = {r.patron_id: r for r in records}
        self.audits: list[tuple[str, str, date]] = []

    def due_records(self, as_of: date) -> list[PatronRecord]:
        return list(self._records.values())

    def anonymize(self, patron_id: str) -> None:
        r = self._records[patron_id]
        self._records[patron_id] = PatronRecord(
            r.patron_id, r.patron_class, r.separation_date, purged_at=date.today()
        )

    def record_audit(self, patron_id: str, action: str, due: date) -> None:
        self.audits.append((patron_id, action, due))


def test_purges_only_overdue_records() -> None:
    today = date(2026, 7, 16)
    overdue = PatronRecord("p1", "student", today - timedelta(days=6 * 365), None)
    current = PatronRecord("p2", "student", today - timedelta(days=365), None)
    store = FakeStore([overdue, current])

    purged = enforce_retention(store, as_of=today)

    assert purged == 1                       # only the overdue record
    assert store.audits == [("p1", "purge", date(2021, 7, 17))]


def test_enforcer_is_idempotent() -> None:
    today = date(2026, 7, 16)
    overdue = PatronRecord("p1", "student", today - timedelta(days=6 * 365), None)
    store = FakeStore([overdue])

    first = enforce_retention(store, as_of=today)
    second = enforce_retention(store, as_of=today)  # re-run same day

    assert first == 1
    assert second == 0                       # already purged, skipped
    assert len(store.audits) == 1            # no duplicate audit entry

For a running pipeline, add a continuous invariant on the patron table itself: periodically re-run the overdue query from Problem Framing and assert the count is zero. If it is ever nonzero, the enforcer is not keeping up — a schedule gap, a class with no policy, or a batch of records with a null separation date — and the batch should alert rather than pass silently. Track the purge count per run over time; a sudden spike after a term rollover is expected, while a run that purges nothing when overdue records exist is your signal that the enforcer stalled.