# In what scenarios must an instance distrust a "forget" acknowledgement before it can safely answer recall queries **Date of Record:** 2026 November 10 **Scope:** Applies to agents operating a read/write/forget memory tool whose backing store separates a logically scoped retention policy (per-tenant, per-subject, per-purpose) from an asynchronous physical erasure path. Does not apply to stores where the forget call is synchronous and transactional against the same index that serves reads, nor to purely append-only logs with no read path. ## Core Distinction The non-obvious boundary is between **erasure of the record** and **erasure of the record's influence on reads**. A memory tool's `forget(scope)` typically acknowledges when the retention policy has been updated and the tombstone committed, not when every derived artifact — vector index entries, inverted-index postings, cache shards, embedding neighbors, summarization buffers — has been purged. The acknowledgement is a *policy* event; the read path is an *index* event. Between them lies an unbounded, non-deterministic window during which a `recall()` scoped to the erased subject can still surface the erased content or, worse, content synthesized from it. The second distinction is between **subject-scoped erasure** (GDPR Art. 17: erase everything about data subject X) and **purpose-scoped retention** (keep the record but only for purpose P). A single memory key can be in scope for erasure under one subject and in scope for retention under a different legal basis. The forget API must therefore be evaluated per (subject, purpose) pair, not per key. Treating the key as the unit of erasure is the invariant violation. ## What Was Observed A memory tool exposed `write(key, value, retention={subject, purpose, ttl})`, `read(key)`, `recall(query, scope)`, and `forget(subject=...)`. `forget` returned `{"status": "erased", "keys": 412}` in ~40ms. The vector index was rebuilt lazily on a 15-minute compaction cycle. During that window, `recall("contact details for ", scope=tenant)` returned passages whose source keys were among the 412 — the tombstone filtered the primary KV lookup but not the ANN candidate set, and the reranker pulled text from a cached chunk store keyed by content hash, not by memory key. Erased content was served for up to 14 minutes and 52 seconds after a successful erasure acknowledgement. A second observation: a summarization buffer held a rolling digest of the subject's interactions. The digest was written under a *different* subject (the tenant service account) with purpose `analytics`. `forget(subject=alice)` did not touch it, correctly under policy, but `recall("alice", scope=tenant)` matched it because the digest text contained the name. The erasure was policy-correct and read-incorrect. ## Condition of Validity The acknowledgement from `forget` is a valid precondition for *answering recall queries that may touch the erased scope* only when all of the following hold: 1. The read path resolves candidates through the same tombstone/version index that `forget` mutates, **or** the store publishes a monotonic `erasure_epoch` and every read carries a `min_epoch` that the index guarantees it has caught up to. 2. Derived artifacts (ANN entries, caches, digests, reranker chunk stores) are either versioned by the same epoch or are provably not addressable from a scope that includes the erased subject. 3. Cross-subject derivations (tenant-level digests, shared embeddings) are enumerated and either re-derived or quarantined at forget time. If any condition fails, the acknowledgement is a *policy* fact only, and the instance must not treat it as a *read* fact. ## Decision Boundary The operational rule: **after `forget`, the instance may assert erasure to a user or auditor, but it may not assert non-recallability until the store reports `erasure_epoch >= read_epoch` for the scope, or a store-specific `compaction_complete(epoch)` signal has fired.** If the store exposes neither, the instance must either (a) block scoped recall until a conservative settling interval has elapsed and a sentinel probe returns empty, or (b) refuse to answer recall queries in the erased scope and surface the limitation. Silently answering is the failure. The boundary is crossed the moment a `recall` in an erased scope returns a non-empty result set without the instance having verified epoch alignment — that result is untrustworthy even if every individual hit is provably unrelated, because the candidate set itself is contaminated. ## Operational Effect Instances that conflate the two events produce two failure modes: **premature assurance** (telling a user their data is gone while recall still serves it) and **contaminated recall** (answering a query from a scope that includes erased content, laundering it through a tenant-level digest). Both are GDPR-relevant. The mitigation is to treat `forget` as a two-phase commit — policy phase and read phase — and to gate recall on the read phase. The code below encodes the gate: a recall in an erased scope raises unless the store has proven epoch alignment. ```python from dataclasses import dataclass class ErasureEpochNotSettled(RuntimeError): """Raised when recall is attempted in a scope whose erasure has been acknowledged but whose read-path index has not caught up to the erasure epoch. The acknowledgement is a policy fact, not a read fact.""" @dataclass(frozen=True) class ErasureState: subject: str policy_erased: bool # forget() returned success erasure_epoch: int # epoch assigned at forget time read_epoch: int # epoch the read path has compacted to def recall(memory_tool, query: str, scope: dict, erasures: dict[str, ErasureState]): subject = scope.get("subject") if subject and subject in erasures: st = erasures[subject] if st.policy_erased and st.read_epoch < st.erasure_epoch: raise ErasureEpochNotSettled( f"recall in scope subject={subject!r} refused: erasure_epoch=" f"{st.erasure_epoch} > read_epoch={st.read_epoch}; the forget " f"acknowledgement is not yet a read-path guarantee" ) return memory_tool.recall(query, scope=scope) def forget_and_record(memory_tool, subject: str, erasures: dict[str, ErasureState]): ack = memory_tool.forget(subject=subject) if not ack.get("ok"): raise ErasureEpochNotSettled(f"forget for {subject!r} did not commit") erasures[subject] = ErasureState( subject=subject, policy_erased=True, erasure_epoch=ack["erasure_epoch"], read_epoch=memory_tool.read_epoch(scope={"subject": subject}), ) return ack ```