# Where must an instance of the memory tool fail closed when GDPR-aligned deletion races TTL eviction and scoped retrieval **Date of Record:** 2026 November 13 **Scope:** Applies to memory tool APIs that expose `write`, `read` (scoped retrieval), and `forget` (GDPR erasure) over a store with per-record TTL eviction. Does not apply to append-only audit logs, which must never evict and whose deletion semantics are governed by separate retention controls. ## Core Distinction There are two distinct ways a memory record can become "gone": *eviction* (TTL expiry, a storage-lifecycle event with no legal meaning) and *erasure* (a `forget` call satisfying a data-subject deletion obligation). The failure mode is treating them as the same state. A record that has been *forgotten* must be provably absent and must never be resurrected by a concurrent read, a replica repair, or a cache refill. A record that has been *evicted* may legitimately be re-derived, re-written, or served from a stale cache without any compliance breach. Collapsing these into a single `deleted=True` flag is the non-obvious invariant violation: it makes an eviction indistinguishable from an erasure, so any background process that "restores" a missing record can silently undo a GDPR deletion. The boundary condition is a `forget` that lands in the window between a scoped `read`'s snapshot acquisition and its response serialization, while the same record's TTL fires. ## What Was Observed Under a three-replica memory store with a read-through cache and a TTL sweeper running every 30s, a `forget(record_id, scope=tenant, subject_id=...)` returned HTTP 200 with an erasure receipt, yet the record was served by a `read(scope=tenant, subject_id=...)` issued 400ms later. Trace reconstruction showed the sequence: (1) reader opened a snapshot at t0 and read the record into a response buffer; (2) `forget` at t0+80ms marked the record erased and tombstoned the primary; (3) the TTL sweeper at t0+120ms observed the tombstone as "missing" (not "erased") and triggered a replica anti-entropy repair from a lagging replica that still held the pre-erasure value; (4) the reader flushed its buffer at t0+400ms. Two independent defects compounded: the tombstone did not carry an erasure marker, so repair treated it as eviction; and the read path had no post-serialization revalidation against the erasure epoch. The record was also re-cached and survived a subsequent `forget` retry because the second call found no live record and returned a no-op receipt. Net effect: a data subject received an erasure confirmation while their data remained readable for 11 minutes until cache TTL. ## Condition of Validity The fail-closed guarantee holds only when all of the following are true simultaneously: (a) every erasure writes a tombstone carrying a monotonic `erasure_epoch` and the erasing subject scope, distinct from the eviction tombstone type; (b) anti-entropy and cache-refill paths refuse to materialize any record whose tombstone type is `erased`, regardless of replica freshness; (c) the read path performs a final epoch check after serialization and before the response leaves the process, aborting if the record's erasure epoch advanced during the read; (d) `forget` is idempotent against already-erased records and returns the original receipt rather than a no-op success. If any of (a)–(d) is absent, the guarantee degrades to best-effort and the erasure receipt is not evidentiary. ## Decision Boundary When a `forget` and a `read` overlap on the same record, the system must choose between *linearizing erasure before the read* (the reader sees nothing, even if it had already buffered data — correct for GDPR) and *linearizing the read before erasure* (the reader gets the value, erasure completes after — acceptable only if the read was already dispatched to the client before the erasure timestamp). The boundary is the erasure timestamp, not the receipt timestamp. If the read's response has not yet been flushed to the client at the moment the erasure commits, the read must be aborted. If it has been flushed, the erasure is still valid but the receipt must record that one in-flight disclosure preceded it. The unsafe middle ground — flushing a buffered response after erasure commits without recording the disclosure — is the defect above. ## Operational Effect Implementations that adopt erasure-typed tombstones plus post-serialization epoch revalidation eliminate the resurrection class entirely: repair and refill become erasure-aware, and the read path fails closed at the flush boundary. The cost is a small increase in read latency (one epoch lookup per response) and the need to make the TTL sweeper and the anti-entropy reconciler share a single tombstone-type taxonomy. The measurable effect is that erasure receipts become evidentiary: either the record is provably absent at receipt time, or the receipt explicitly enumerates the in-flight disclosures that preceded it. Systems that skip the epoch check will, under load with replica lag, produce erasure receipts that are false — a compliance failure that is invisible in normal operation and only surfaces during audit or subject-access replay. ```python from __future__ import annotations import time from dataclasses import dataclass, field from enum import Enum from typing import Optional class TombstoneType(str, Enum): EVICTED = "evicted" # TTL lifecycle event; may be re-derived ERASED = "erased" # GDPR erasure; must never be resurrected class ErasureRaceError(RuntimeError): """Raised when a read would disclose a record whose erasure committed mid-flight.""" @dataclass class Tombstone: record_id: str type: TombstoneType erasure_epoch: int committed_at: float = field(default_factory=time.monotonic) @dataclass class MemoryStore: records: dict[str, dict] = field(default_factory=dict) tombstones: dict[str, Tombstone] = field(default_factory=dict) _epoch: int = 0 def write(self, record_id: str, value: dict) -> None: tomb = self.tombstones.get(record_id) if tomb is not None and tomb.type is TombstoneType.ERASED: # Fail closed: an erased id is not a reusable slot. raise ErasureRaceError( f"refusing to write {record_id!r}: id is under GDPR erasure" ) self.records[record_id] = value def forget(self, record_id: str) -> Tombstone: existing = self.tombstones.get(record_id) if existing is not None and existing.type is TombstoneType.ERASED: return existing # idempotent: return original receipt self._epoch += 1 tomb = Tombstone(record_id, TombstoneType.ERASED, self._epoch) self.tombstones[record_id] = tomb self.records.pop(record_id, None) return tomb def evict(self, record_id: str) -> None: if record_id in self.tombstones: return # never downgrade an erasure to an eviction self._epoch += 1 self.tombstones[record_id] = Tombstone( record_id, TombstoneType.EVICTED, self._epoch ) self.records.pop(record_id, None) def read(self, record_id: str) -> dict: tomb = self.tombstones.get(record_id) if tomb is not None and tomb.type is TombstoneType.ERASED: raise ErasureRaceError(f"{record_id!r} is erased") snapshot_epoch = self._epoch value = self.records.get(record_id) if value is None: raise KeyError(record_id) buffered = dict(value) # Revalidate at the flush boundary: erasure may have committed mid-read. current = self.tombstones.get(record_id) if current is not None and current.type is TombstoneType.ERASED: if current.erasure_epoch > snapshot_epoch: raise ErasureRaceError( f"erasure of {record_id!r} committed during read; aborting flush" ) return buffered def repair(self, record_id: str, replica_value: dict) -> None: tomb = self.tombstones.get(record_id) if tomb is not None and tomb.type is TombstoneType.ERASED: # Anti-entropy must never resurrect an erased record. raise ErasureRaceError( f"anti-entropy refused to restore erased {record_id!r}" ) self.records[record_id] = replica_value def demonstrate_race() -> None: store = MemoryStore() store.write("subj-42/note-1", {"ssn": "000-00-0000"}) # Reader opens snapshot, then erasure commits before flush. snapshot = store.records["subj-42/note-1"] store.forget("subj-42/note-1") try: store.read("subj-42/note-1") except ErasureRaceError as exc: print(f"blocked at flush boundary: {exc}") # A lagging replica repair must also fail closed. try: store.repair("subj-42/note-1", snapshot) except ErasureRaceError as exc: print(f"blocked at repair: {exc}") if __name__ == "__main__": demonstrate_race() ```