Mapping Retention Schedules to Statutory Limits

This guide shows how to encode statutory retention periods by record type and jurisdiction into a version-controlled schedule, then compute a deterministic retain-until date for any record so that disposition decisions are reproducible and audit-defensible.

Prerequisites & Version Matrix Permalink to this section

The schedule produced here is the data the eligibility engine in Legal Hold & Retention Automation reads; this page is concerned only with defining the clocks correctly, not with the hold override.

Component Version Role
Python 3.11+ dateutil interop, zoneinfo, dataclasses
python-dateutil 2.9+ calendar-correct year/month arithmetic
PyYAML 6.0+ loads the version-controlled schedule file
pytest 8.0+ asserts retain-until determinism

A schedule entry pairs a (record_type, jurisdiction) key with a retention period and its statutory citation. Keeping citations in the data — not in code comments — lets compliance reviewers audit the mapping directly and lets a loader validate that no period drifts below its legal floor.

Step 1 — Encode the Schedule as Version-Controlled Data Permalink to this section

Store the schedule as YAML in the same repository as the pipeline, reviewed through pull requests. Every period carries the exact provision it derives from.

# retention_schedule.yaml  (v3, reviewed 2026-06-01)
- record_type: broker_dealer_comms
  jurisdiction: US
  period_months: 72            # 6 years
  basis: "SEC Rule 17a-4(b)(4)"
- record_type: eu_personal_data
  jurisdiction: EU
  period_months: 36            # storage-limitation ceiling, policy-set
  basis: "GDPR Article 5(1)(e)"
- record_type: eu_personal_data
  jurisdiction: DE
  period_months: 120           # 10 years, German commercial retention
  basis: "HGB Section 257(4)"

Step 2 — Load and Validate Against Statutory Floors Permalink to this section

The loader rejects any entry whose period falls below a known statutory minimum, so a mis-edit fails the deploy rather than quietly under-preserving records.

import yaml
from dataclasses import dataclass

# Hard floors the schedule may never dip below (record_type -> min months).
STATUTORY_FLOORS = {"broker_dealer_comms": 72}

@dataclass(frozen=True)
class ScheduleEntry:
    record_type: str
    jurisdiction: str
    period_months: int
    basis: str

def load_schedule(path: str) -> dict[tuple[str, str], ScheduleEntry]:
    with open(path, "r", encoding="utf-8") as fh:
        raw = yaml.safe_load(fh)
    schedule: dict[tuple[str, str], ScheduleEntry] = {}
    for row in raw:
        entry = ScheduleEntry(**row)
        floor = STATUTORY_FLOORS.get(entry.record_type)
        if floor is not None and entry.period_months < floor:
            raise ValueError(
                f"{entry.record_type}/{entry.jurisdiction} below statutory floor "
                f"({entry.period_months} < {floor} months, {entry.basis})")
        schedule[(entry.record_type, entry.jurisdiction)] = entry
    return schedule

Step 3 — Compute a Deterministic Retain-Until Date Permalink to this section

Given a record’s trigger date and its applicable jurisdictions, resolve the governing period and add it with calendar-correct arithmetic. When jurisdictions conflict, the longer period wins — a record must satisfy the strictest applicable law.

from datetime import date
from dateutil.relativedelta import relativedelta

def retain_until(schedule: dict[tuple[str, str], ScheduleEntry],
                 record_type: str, jurisdictions: list[str],
                 trigger_date: date) -> tuple[date, str]:
    """Return (retain_until_date, governing_basis). Longest period governs."""
    candidates = [schedule[(record_type, j)]
                  for j in jurisdictions if (record_type, j) in schedule]
    if not candidates:
        raise KeyError(f"no schedule entry for {record_type} in {jurisdictions}")
    governing = max(candidates, key=lambda e: e.period_months)
    # relativedelta gives calendar-correct months (handles leap years, month ends).
    until = trigger_date + relativedelta(months=governing.period_months)
    return (until, governing.basis)

Compliance Checkpoint Permalink to this section

GDPR Article 5(1)(e) requires that personal data be “kept in a form which permits identification of data subjects for no longer than is necessary” — the storage-limitation principle. Encoding a concrete ceiling (here 36 months for eu_personal_data) turns that principle into an enforceable clock the disposition engine can act on. The concrete example matters: German commercial records fall under HGB Section 257(4), which mandates a ten-year retention for accounting documents, so an EU personal-data record that is also a German commercial record must be retained for 120 months, not 36. Resolving that tension by taking the longer period is what keeps the schedule lawful in every jurisdiction it touches; the broader erasure-regime comparison lives in GDPR vs CCPA Redaction Requirements.

Gotchas Permalink to this section

  • Jurisdiction conflicts take the longer period, never the shorter. A record subject to two regimes must satisfy the strictest; max on period length is the safe default, and picking the shorter risks premature deletion of a record another law still compels you to keep.
  • Trigger-date ambiguity poisons every computed date. “Retention starts at creation” and “retention starts at matter close” produce very different retain-until values. Fix the trigger semantics per record type in the schedule and record which event set the clock.
  • Schedule changes must not retroactively shorten retention. Shortening a period should apply only to records whose clock starts after the change; version the schedule and pin each record to the schedule version in force at its trigger date, so an edit can never make an already-retained record suddenly disposable.
  • Use calendar arithmetic, not timedelta(days=365 * n). Fixed-day math drifts across leap years; relativedelta(months=...) lands on the correct calendar date every time.

Verification & Testing Permalink to this section

The retain-until date must be a pure function of the schedule and trigger date — same inputs, same output, forever. A deterministic assertion locks that in.

def test_retain_until_is_deterministic_and_takes_longer_period():
    schedule = {
        ("eu_personal_data", "EU"): ScheduleEntry(
            "eu_personal_data", "EU", 36, "GDPR Article 5(1)(e)"),
        ("eu_personal_data", "DE"): ScheduleEntry(
            "eu_personal_data", "DE", 120, "HGB Section 257(4)"),
    }
    until, basis = retain_until(
        schedule, "eu_personal_data", ["EU", "DE"], date(2026, 7, 15))
    # 120 months (DE) governs over 36 (EU); result is fixed and reproducible.
    assert until == date(2036, 7, 15)
    assert basis == "HGB Section 257(4)"

Running pytest -q prints 1 passed, confirming the longer German period governs and the computed date is stable across runs.

This schedule is consumed by the eligibility engine in Legal Hold & Retention Automation and complements Automating Litigation Hold Triggers, whose holds suspend the clocks defined here. Records that reach their retain-until date and carry no hold are disposed from the write-once tier described in WORM Storage Synchronization. Part of the Immutable Audit Trails & Chain of Custody field guide.