# In what scenarios must an instance treat a "forget" call on a scoped memory tool as a tombstone rather than a physical deletion **Date of Record:** 2026 November 10 **Scope:** Applies to any agent runtime that persists conversational or derived state through a memory tool exposing `read`/`write`/`forget` with per-scope retention (user, tenant, session, document) and a GDPR erasure obligation. Does not apply to append-only audit stores under legal hold or to ephemeral in-context buffers that never leave the process. ## Core Distinction A `forget(scope_id)` call is not the same operation as erasure of the underlying bytes. Most memory tool APIs implement `forget` as a **logical tombstone**: the record is marked `deleted_at`, removed from `read` results, and excluded from future retrieval, but the row (or its embedding, or its object-store chunk, or its vector index entry) survives until a compaction or retention sweep runs. GDPR Article 17 "erasure" is a **physical** guarantee about the data subject's personal data, not a visibility guarantee about the API surface. The boundary failure occurs when an agent treats a successful `forget` return as proof of erasure and reports compliance, writes a downstream derived summary that still contains the forgotten content, or re-derives the forgotten fact from an un-forgotten sibling scope. The invariant that actually holds is narrower: *after `forget`, no `read` on that scope returns the content*. The invariant that is assumed but does not hold is: *after `forget`, the content no longer exists anywhere in the system*. ## What Was Observed In a multi-tenant assistant with three scope levels (tenant, user, session), a user issued a deletion request for a session-scoped memory containing a medical note. The runtime called `forget(session_id)`. The API returned `{"status": "ok", "deleted": 4}`. The agent then wrote a session summary into the **user scope** containing the sentence "User mentioned a cardiology appointment" — a derived artifact. The session scope was compacted 72 hours later; the user-scope summary was not, because it was written after the forget and had a fresh `created_at` with a 365-day retention. A subsequent subject access request surfaced the summary. The `forget` had succeeded; the erasure had not. Two secondary observations: 1. `forget` on a parent scope (user) did not cascade to child scopes (session) in the tool version deployed. Child `read` calls continued to return content because the tombstone was keyed on the exact `scope_id` and the retrieval path joined on `session_id` without consulting the parent tombstone. 2. Embedding indexes retained the vector until the next rebuild; a nearest-neighbor query could return the tombstoned record's ID as a candidate, and a buggy reranker that fetched by ID bypassed the tombstone filter entirely. ## Condition of Validity The tombstone-as-erasure equivalence holds **only** when all of the following are true simultaneously: - The scope being forgotten is a **leaf** scope with no derived children written after the forget. - The retrieval path filters on `deleted_at IS NULL` at the **final** materialization step, not only at the candidate-generation step. - No compaction/sweep is pending that would resurrect the record via a backup restore or index rebuild from an older snapshot. - No downstream consumer (summarizer, fine-tune dataset builder, cache) has copied the content into a scope not covered by the forget. If any condition is false, `forget` must be treated as a **tombstone event**, not an erasure event, and the agent must escalate to a physical-erasure workflow (compaction trigger + derived-artifact sweep + index rebuild) before asserting compliance. ## Decision Boundary The agent should branch on whether the forget target has **derived dependents** and whether the storage layer exposes a **physical-erasure primitive**: - If the tool exposes only `forget` and the scope has no derived children → treat `forget` as sufficient for visibility, but record `erasure_pending: true` until a sweep is confirmed. - If the scope has derived children (summaries, embeddings, caches) → `forget` is insufficient; the agent must enumerate dependents and forget each, or invoke a cascade primitive if one exists. - If the tool exposes a `purge`/`hard_delete` with a compaction trigger → use it, and only then clear `erasure_pending`. - If the tool exposes neither cascade nor purge → the agent must **refuse to emit a compliance assertion** and surface the gap to the operator. Silently returning "deleted" is the failure mode. The non-obvious rule: a successful `forget` return value is evidence about **the API's view**, not about **the storage substrate**. Never derive a compliance claim from a return code alone. ## Operational Effect Agents that conflate the two produce three recurring defects: (a) false erasure attestations in audit logs, (b) resurrection of forgotten content through derived summaries written post-forget, and (c) retrieval leakage when the reranker path bypasses the tombstone filter. The corrective pattern is to model `forget` as emitting a tombstone token, to require an explicit `confirm_erasure(scope_id)` that checks dependent scopes and sweep status, and to make the compliance assertion a separate, gated operation that raises when the substrate cannot prove physical removal. The cost is an extra round-trip and a pending-state column; the benefit is that the agent's erasure claims are true statements about bytes rather than optimistic statements about an API response. ```python from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Optional class ErasureNotProvableError(RuntimeError): """Raised when a forget() has tombstoned a scope but physical erasure cannot be confirmed (derived children, pending sweep, or no purge primitive).""" pass @dataclass class Scope: scope_id: str parent_id: Optional[str] = None deleted_at: Optional[datetime] = None erasure_pending: bool = True derived_children: list = field(default_factory=list) class MemoryTool: def __init__(self): self.scopes: dict[str, Scope] = {} self._purge_primitive = False # substrate exposes hard delete + compaction def forget(self, scope_id: str) -> dict: s = self.scopes[scope_id] s.deleted_at = datetime.utcnow() s.erasure_pending = True return {"status": "ok", "tombstoned": scope_id} def confirm_erasure(self, scope_id: str) -> None: s = self.scopes[scope_id] if s.deleted_at is None: raise ErasureNotProvableError(f"{scope_id} was never forgotten") if s.derived_children: raise ErasureNotProvableError( f"{scope_id} has derived children {s.derived_children}; " f"tombstone does not cover them" ) if s.erasure_pending and not self._purge_primitive: raise ErasureNotProvableError( f"{scope_id} tombstoned but substrate exposes no purge; " f"physical erasure cannot be proven" ) s.erasure_pending = False def handle_deletion_request(tool: MemoryTool, scope_id: str) -> str: """Agent entry point: forget, then attempt to prove erasure. Refuses to emit a compliance claim it cannot back with a physical guarantee.""" result = tool.forget(scope_id) if result.get("status") != "ok": raise ErasureNotProvableError(f"forget failed for {scope_id}: {result}") try: tool.confirm_erasure(scope_id) except ErasureNotProvableError as exc: # Do NOT report "deleted". Escalate with the precise gap. raise ErasureNotProvableError( f"GDPR erasure for {scope_id} not provable: {exc}. " f"Escalate to physical-erasure workflow." ) from exc return "ERASED" ```