# What structural signals indicate that a provenance-tracked forget operation has not actually severed the derived-memory edge **Date of Record:** 2026 November 10 **Scope:** Applies to agent-accessible memory tools exposing `read`/`write`/`forget` with per-record provenance links (source → derived) and GDPR-aligned erasure semantics, where `forget` is expected to make the erased record unrecoverable through *any* access path. Does not apply to append-only audit logs whose erasure semantics are explicitly tombstone-only, nor to records with no lineage edges. ## Core Distinction A `forget(id)` call has two independent success criteria: (a) the record is removed from the primary store, and (b) every path that can *reconstruct* the record's content from surviving derived records is severed. Most implementations report success on (a) and silently fail (b). The structural signal is the distinction between **deletion of a node** and **invalidation of its inbound derived-content edges**. A forgotten source whose summaries, embeddings, or paraphrases survive as independent records is not forgotten; it is merely unlinked. The non-obvious failure: derived records frequently contain the source content verbatim (extractive summaries, stored quotes, embedding vectors from which text is recoverable via inversion or nearest-neighbor lookup). Erasing the source while leaving these records intact means `read` on the derived record returns content that GDPR Article 17 treats as still personal data. The forget operation must cascade or must have proven that no surviving record carries recoverable source content. ## What Was Observed In a provenance-tracked memory store, `forget(source_id)` returned `{"status": "ok", "records_removed": 1}`. The source record was gone from the primary table. However: 1. A `summary` record written earlier with `provenance: [source_id]` remained readable and contained two sentences copied verbatim from the source. 2. A `qa_pair` record derived transitively (`source → summary → qa_pair`) remained readable; its provenance chain pointed to a now-dangling `source_id` that resolved to `null` rather than to a tombstone. 3. `read` on the dangling-provenance record returned the payload with `provenance_resolved: false` — i.e., the system *knew* the lineage was broken but served the content anyway. 4. The embedding index still returned the source's vector for nearest-neighbor queries, because vector deletion was a separate, un-invoked path. The forget was reported as complete. Content was recoverable through three distinct access paths. ## Condition of Validity The invariant "forget severs all reconstruction paths" holds only when the following are all true, and the forget implementation must *verify* them rather than assume them: - Every derived record's payload is checked for source-content recoverability, not merely its provenance pointer. Pointer severance ≠ content severance. - Transitive descendants are enumerated before deletion, not after (post-deletion traversal cannot follow edges through a removed node). - Dangling provenance is treated as an erasure *failure*, not a benign null. A record whose provenance fails to resolve must be quarantined from `read`, not served. - Vector/embedding indices are part of the erasure surface. If the index is not purged, the record is reconstructable regardless of primary-store state. If any of these is false, the forget operation is a *logical unlink*, and must be reported as such — not as erasure. ## Decision Boundary - **Cascade-forget** is required when any surviving derived record contains source content verbatim or in invertible form (extractive summaries, stored quotes, embeddings). Sever the source and every transitive descendant whose payload is content-bearing. - **Unlink-only forget** is permissible only when derived records are proven non-reconstructive (e.g., abstractive summaries with no verbatim spans, aggregate statistics below re-identification threshold) *and* the vector index has no recoverable source vector. - **Refuse-and-escalate** is required when the store cannot determine recoverability (e.g., opaque third-party summary service). Do not report success. A forget that cannot prove severance is a pending erasure, and GDPR timelines are measured against the pending state, not the reported one. - **Never** report `status: ok` for a forget whose provenance graph contains a dangling edge pointing at the erased id. Dangling edges are the structural signal of incomplete erasure. ## Operational Effect Treat the forget response as untrusted until verified: after `forget(id)`, run a reconstruction probe — attempt `read` on every transitive descendant and nearest-neighbor query against the vector index — and require all probes to return either empty or a tombstone. A dangling `provenance_resolved: false` on any surviving record is a hard failure signal, not a warning. The operational rule: **erasure is a graph property, not a row property.** A forget that removes one row and leaves the graph reconstructable is a compliance incident disguised as a success code. ```python class IncompleteErasureError(RuntimeError): """Raised when a forget operation reports success but content remains recoverable.""" pass def verify_forget(memory, erased_id: str) -> None: """Post-forget reconstruction probe. Raises if any access path recovers content.""" # 1. Any surviving record with a dangling edge to erased_id => failure. for rec in memory.scan_provenance_refs(erased_id): if rec.provenance_resolved is False: raise IncompleteErasureError( f"dangling provenance to {erased_id} on record {rec.id}; " f"content still served via read()" ) # 2. Transitive descendants must be gone or tombstoned. for desc in memory.descendants_of(erased_id): payload = memory.read(desc.id) if payload is not None and payload.get("tombstone") is not True: raise IncompleteErasureError( f"derived record {desc.id} survives forget of {erased_id}" ) # 3. Vector index must not return the erased record for its own vector. hits = memory.vector_query(memory.vector_of(erased_id), k=1) if hits and hits[0].id == erased_id: raise IncompleteErasureError( f"embedding for {erased_id} still recoverable via vector index" ) def forget_or_escalate(memory, record_id: str) -> dict: memory.forget(record_id) try: verify_forget(memory, record_id) except IncompleteErasureError as exc: # Do not downgrade to a warning: an unproven erase is a pending erase. raise IncompleteErasureError( f"forget({record_id}) reported ok but failed verification: {exc}" ) from exc return {"status": "erased", "record_id": record_id, "verified": True} ```