# Which invariants prevent silent resurrection of GDPR-erased memory records under scoped retention policies **Date of Record:** 2026 November 12 **Scope:** Applies to memory tool APIs that expose `read`, `write`, and `forget` operations over records governed by overlapping retention scopes (tenant, project, user, session) and where at least one scope carries a legal erasure obligation (GDPR Art. 17). Does not apply to append-only audit logs whose retention is governed solely by statutory hold and which are physically immutable. ## Core Distinction The critical distinction is between **erasure of the record** and **erasure of the record's derivations and pointers**. Most memory tool APIs implement `forget(record_id)` as a tombstone or hard-delete on the primary key. This satisfies the naive reading of "right to be forgotten" but leaves three classes of residue that can resurrect the erased content: 1. **Derived summaries and embeddings** — a nightly compaction job that summarizes a user's session into a profile record, or an embedding index entry keyed by content hash rather than record ID. 2. **Cross-scope references** — a project-scoped record that embeds a verbatim quote from a user-scoped record; forgetting the user record leaves the quote in the project record. 3. **Replication lag windows** — a read replica, cache tier, or export snapshot that has not yet observed the tombstone and will serve the content, then re-derive it on next compaction. The invariant that must hold is: *for every record r and every retention scope S such that r ∈ S, `forget(r)` must render r unreadable through every read path, including derived and replicated paths, before it returns.* A tombstone on the primary key alone violates this. ## What Was Observed In a deployment with user-scoped and project-scoped retention, `forget(user_record_42)` returned success in 12 ms. The primary store tombstoned the row. However: - The embedding index was keyed by `content_sha256`, not `record_id`. The vector remained queryable and, on nearest-neighbor lookup, returned the erased content as the top hit because the index stored the literal text alongside the vector. - A compaction job running 40 minutes later read the tombstoned row's *pre-deletion snapshot* from a change-data-capture stream and wrote a derived profile record scoped to the project. The derived record did not inherit the user-scope erasure flag. - The read path for project-scoped records did not consult the user-scope tombstone table because the scopes were modeled as independent namespaces. Result: the erased content was readable via a project-scoped read 40 minutes after a successful `forget`. This is a silent resurrection — no error was raised, and the API contract appeared satisfied. ## Condition of Validity The tombstone-only `forget` implementation is valid **only if** all of the following hold: - No derived record is ever written from a source record without copying the source's erasure lineage (a set of `(scope, record_id)` tombstones that must be checked on every read). - No secondary index is keyed by content rather than by record identity, or, if it is, the index entry is deleted synchronously with the tombstone. - The read path for every scope performs a lineage check against all ancestor scopes' tombstone tables, not just its own. - The CDC/compaction pipeline is either drained before `forget` returns or is made lineage-aware so it cannot derive from an erased source. If any of these fail, the invariant is violated and resurrection is possible. In the observed deployment, all four failed. ## Decision Boundary The operational decision is whether to make `forget` **synchronous and transitive** (drain CDC, delete content-keyed index entries, write lineage tombstones, and block until replicas acknowledge) or **asynchronous with a quarantine window** (mark erased, and serve no reads from any scope for a bounded interval until derivations converge). The former is correct but expensive (12 ms becomes hundreds of ms under replication). The latter is cheaper but requires a hard guarantee that no read path — including derived reads — can serve quarantined content, which in practice requires the same lineage check as the synchronous path. There is no correct middle ground that skips lineage propagation: a tombstone that does not propagate to derivations is a guarantee that is false the moment compaction runs. ## Operational Effect After the incident, `forget` was reimplemented to (a) write a lineage tombstone keyed by `(scope, record_id)` into a shared erasure table consulted by all read paths, (b) synchronously delete content-keyed index entries by resolving `content_sha256` from the record before tombstoning, and (c) make the compaction job refuse to derive from any source with an active lineage tombstone, raising a hard error instead of silently skipping. The cost was a 40× latency increase on `forget` and a new failure mode: compaction jobs now fail loudly when they encounter erased sources, which required an operator runbook. The benefit is that resurrection is now impossible by construction rather than by hope. ```python class ErasureLineageViolation(RuntimeError): """Raised when a read or derivation path encounters content whose erasure lineage is active but not honored by the caller.""" pass class ErasureLedger: def __init__(self): self._tombstones: set[tuple[str, str]] = set() def forget(self, scope: str, record_id: str) -> None: self._tombstones.add((scope, record_id)) def is_erased(self, scope: str, record_id: str) -> bool: return (scope, record_id) in self._tombstones def derive_profile(ledger: ErasureLedger, source_scope: str, source_id: str, content: str) -> dict: # A derivation must refuse to run if its source carries an active # erasure tombstone; silently skipping would let the erased content # propagate through a different code path. if ledger.is_erased(source_scope, source_id): raise ErasureLineageViolation( f"refusing to derive from erased source " f"{source_scope}/{source_id}" ) return {"derived_from": (source_scope, source_id), "text": content} def read_project_record(ledger: ErasureLedger, record: dict) -> str: # Project-scoped reads must consult the lineage of every ancestor # scope embedded in the record, not just the project tombstone table. for (scope, rid) in record.get("lineage", []): if ledger.is_erased(scope, rid): raise ErasureLineageViolation( f"read of project record blocked: ancestor " f"{scope}/{rid} is erased" ) return record["text"] ```