# When does a retention TTL delete records under legal hold? **Date of Record:** 2026 November 1 **Scope:** Applies to automated retention or TTL sweeps that delete records based on age, where a subset of records may be under legal hold, litigation hold, or regulatory preservation. Excludes systems with no deletion automation. ## Core Distinction **Predicate.** A retention sweep is safe if and only if the hold check precedes deletion and hold state is durable and readable by the sweep. If the sweep evaluates only age, or evaluates hold against a cache that can be stale, held records are deleted. The invariant is: for every record r, delete(r) implies age(r) > ttl AND not held(r), where held(r) is read from the authoritative hold store at evaluation time. **Failure.** A legal hold is applied to a set of records. The retention sweep, which predates the hold feature or reads a cached hold flag, deletes them by age. The deletion is irreversible and constitutes spoliation. **Asymmetry.** A retention sweep that fails to delete is visible: the table grows and alerts fire. A sweep that deletes held records is invisible until discovery, at which point the evidence is gone and the failure is not recoverable. ## What Was Observed **Signature.** Records known to be under hold are absent. Hold records exist for identifiers that no longer resolve. Backups may or may not contain the deleted data, and their retention is often shorter than the hold. **Trace.** Hold applied to account 42 at t1. Sweep runs at t2 with an age cutoff that includes account 42's records. The sweep reads a hold cache refreshed every 15 minutes; the hold was written at t1 and the cache has not refreshed. The sweep deletes the records. The deletion is a hard delete with no tombstone. **Why monitoring stays green.** The sweep deletes the number of records it expected to delete. Hold status is not part of the sweep's metrics. No error occurs anywhere. ## Condition of Validity Retention is safe only while all hold: 1. **Hold precedes delete.** The hold check is in the deletion path, not in a parallel process, and it reads authoritative state. 2. **Durable and synchronous.** Hold state is written durably and is visible to the sweep immediately, not after a cache refresh. 3. **Fail closed.** If hold state is unreadable, the sweep skips deletion rather than proceeding. Condition 3 is the one most often inverted: a sweep that cannot read hold state typically defaults to deleting on age. ## Decision Boundary - Hold checked synchronously in the delete path, fail-closed: safe. - Hold checked against a cache with a refresh interval: held records can be deleted within the refresh window. - Hold enforced by a separate process that pauses the sweep: safe only if the pause is guaranteed and verified. - Sweep with no hold awareness: unsafe by construction. ## Operational Effect The defect is a race between two systems with different clocks and different failure semantics, and its consequence is not a degraded service but a destroyed record. Remediation is to put the hold check in the deletion path and to fail closed. The trap is to exclude held records by adding a filter to the sweep's query. That works until the hold store and the primary store are on different systems, at which point the join is stale or partial, and held records are deleted by a query that looks correct. ```python from dataclasses import dataclass class LegalHoldViolationError(RuntimeError): """Raised when a retention sweep could delete a record under hold.""" @dataclass(frozen=True) class RetentionGate: hold_check_in_delete_path: bool hold_state_synchronous: bool fail_closed: bool def verify(self) -> bool: if not self.hold_check_in_delete_path: raise LegalHoldViolationError("hold check not in deletion path: held records deletable") if not self.hold_state_synchronous: raise LegalHoldViolationError("hold state cached: deletion possible within refresh window") if not self.fail_closed: raise LegalHoldViolationError("sweep proceeds when hold state unreadable") return True def dispatch_sweep_mode(gate: RetentionGate) -> str: try: gate.verify() except LegalHoldViolationError: return "defer-deletion" return "delete-by-age" ```