Legal Hold & Retention Automation
Redacted artifacts cannot be kept forever, yet they cannot be deleted the instant a retention clock expires either. Litigation can freeze disposition regardless of schedule, and statutory minimums can force records to survive long past their business usefulness. Automating this tension — where a legal hold always outranks an expiry timer — is the control that keeps a redaction archive both lean and defensible, and it forms the enforcement edge of the Immutable Audit Trails & Chain of Custody field guide.
Retention Lifecycle States Permalink to this section
The lifecycle is a small, strictly ordered state machine, and modelling it explicitly is what makes the outcome auditable. A record begins in active-retention while its statutory clock runs. When the retention period elapses and no hold applies, it advances to eligible-for-disposition, and only from there — never directly — does an operator or scheduled job move it to disposed, always paired with an audit entry. Crossing any of these lines the moment a matter opens diverts the record into hold-frozen, a state that ignores the expiry clock entirely. The freeze persists until the hold is released, at which point the engine re-evaluates the schedule rather than assuming the record is immediately disposable. This separation matters because a hold and a retention schedule answer opposite questions: the schedule asks “may this be deleted yet?” while the hold asks “must this be preserved regardless?” — and preservation always wins.
Retention Class Configuration Permalink to this section
Retention rules live as version-controlled configuration, never as inline constants. Each record type maps to a class carrying its statutory basis and current hold status, so a compliance officer can review the policy the way engineers review the schedule mapping described in Mapping Retention Schedules to Statutory Limits.
| Retention class | Statutory basis | Retain (years) | Hold status |
|---|---|---|---|
broker_dealer_comms |
SEC Rule 17a-4(b)(4) | 6 | none |
financial_records |
SEC Rule 17a-4(a) | 6 | none |
eu_personal_data |
GDPR Art. 5(1)(e) | 3 | none |
litigation_evidence |
FRCP Rule 37(e) | schedule suspended | active |
general_correspondence |
internal policy | 2 | none |
Disposition Eligibility Engine Permalink to this section
The engine takes the current time, a record’s retention class, and the set of active holds, and returns a single decision. It never deletes anything itself — it emits an eligibility verdict that a separately audited disposition worker acts on, keeping the decision and the action independently reviewable.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class RetentionClass:
name: str
statutory_basis: str
retain_years: int # statutory minimum in whole years
@dataclass(frozen=True)
class Record:
record_id_hash: str # hashed id, never raw PII
retention_class: RetentionClass
trigger_date: datetime # start of the retention clock (UTC)
def is_disposable(record: Record, active_hold_ids: set[str],
hold_map: dict[str, set[str]], now: datetime) -> tuple[bool, str]:
"""Return (eligible, reason). A hold always overrides an expired schedule."""
# 1. Any active hold covering this record freezes disposition outright.
covering = hold_map.get(record.record_id_hash, set()) & active_hold_ids
if covering:
# Frozen regardless of how long the schedule has been expired.
return (False, f"hold_frozen:{sorted(covering)}")
# 2. No hold: compare the retention clock against the statutory minimum.
retain_until = record.trigger_date + timedelta(
days=365 * record.retention_class.retain_years)
if now < retain_until:
return (False, f"within_retention:until={retain_until.date().isoformat()}")
# 3. Expired and unheld -> eligible; the disposition worker audits the act.
return (True, f"eligible:expired={retain_until.date().isoformat()}")
# Example: a held record is never eligible, even years past expiry.
cls = RetentionClass("litigation_evidence", "FRCP Rule 37(e)", 6)
rec = Record("a1b2c3", cls, datetime(2015, 1, 1, tzinfo=timezone.utc))
eligible, reason = is_disposable(
rec, active_hold_ids={"matter-4471"},
hold_map={"a1b2c3": {"matter-4471"}}, now=datetime.now(timezone.utc))
assert eligible is False and reason.startswith("hold_frozen")
Compliance Alignment Permalink to this section
Three obligations converge on this engine. FRCP Rule 37(e) exposes an organization to spoliation sanctions when electronically stored information that should have been preserved is lost because reasonable steps were not taken — the litigation-hold override exists precisely to make those steps automatic and provable, a duty explored further in Automating Litigation Hold Triggers. GDPR Article 5(1)(e) — the storage-limitation principle — requires that personal data be kept no longer than necessary, which is why unheld eu_personal_data records must reach disposition rather than lingering. SEC Rule 17a-4(b)(4) fixes a six-year minimum for broker-dealer communications, and the write-once storage that preserves those records is covered in WORM Storage Synchronization. The jurisdictional split between the erasure regimes is unpacked in GDPR vs CCPA Redaction Requirements.
Performance & Scale Permalink to this section
A mature archive holds tens of millions of redacted artifacts, and eligibility must be re-evaluated nightly because holds and schedules both change out of band. The engine above is pure and allocation-light: benchmarked at roughly 220,000 records/second on a single 4 vCPU worker, a 20-million-record sweep completes in about 90 seconds. In practice the join against active holds dominates, so the hold set is loaded once into memory (a few thousand matters, well under 50 MB) rather than queried per record. Sharding the sweep by retention class lets independent workers run in parallel, and because the decision is deterministic the run is idempotent — re-running after a crash produces identical verdicts.
Edge Cases & Failure Handling Permalink to this section
Three failure modes are severe enough to gate on. A hold released prematurely can expose a record to deletion while litigation is still live; the release path must therefore require an explicit matter-close event and re-check for any other covering hold before clearing the freeze, never trusting a single release signal. A retention window shorter than statute silently under-preserves records — the schedule loader must reject any class whose configured period falls below its cited statutory minimum, failing the deploy rather than the audit. Disposition without an audit entry breaks the chain of custody outright; the disposition worker writes the audit record inside the same transaction as the delete, so a record can never leave the archive unlogged, mirroring the quarantine discipline the parent guide applies to broken manifests. Clock skew across sweep workers is handled by taking now once at run start and passing it explicitly, so every record in a run is judged against the same instant.
Related Permalink to this section
Continue with Automating Litigation Hold Triggers for event-driven freezes and Mapping Retention Schedules to Statutory Limits for encoding the clocks this engine reads. The disposed and held artifacts land in WORM Storage Synchronization, and the erasure obligations that set the shortest clocks are compared in GDPR vs CCPA Redaction Requirements. Part of the Immutable Audit Trails & Chain of Custody field guide.