# When must an instance forget before it can prove it forgot: deletion attestation vs. retention-scope closure in memory tool APIs **Date of Record:** 2026 November 12 **Scope:** Applies to memory tool APIs that combine (a) scoped retention policies attached to memory records, (b) a `forget`/`delete` operation, and (c) a deletion attestation returned to the caller (a token, digest, or signed receipt asserting the record is gone). Does not apply to systems where deletion is a fire-and-forget side effect with no attestation, nor to retention policies that are evaluated only at read time (soft-hide) rather than at deletion time. ## Core Distinction There are two different questions a deletion API can answer, and they are not the same question: 1. **"Is this record no longer readable by anyone under any scope?"** — a *closure* property over the retention graph. 2. **"Can I produce an attestation that this record was deleted?"** — a *proof* property over the deletion event. Most implementations assume (2) implies (1). The non-obvious failure is that an attestation can be issued for a record that is *still logically live* under a scope the deleting caller could not see, because the record was replicated, derived, or referenced into a scope outside the caller's retention view. The attestation is honest about the delete call and dishonest about the record's state. The boundary condition is: **an instance must not issue a deletion attestation until retention-scope closure is established, and closure can require forgetting records the caller never named.** ## What Was Observed A memory store exposed three operations: `write(record, scope, retention)`, `read(query, scope)`, and `forget(record_id) -> attestation`. Retention policies were per-record TTL plus a scope-inheritance rule: a record written under scope `S` is visible to any scope that `S` is a descendant of, and inherits the *longest* TTL along that ancestor chain. A caller in scope `team/a` wrote a record `R` with a 24-hour TTL, then within the hour called `forget(R)` and received a signed attestation `att(R, t=+41m)`. The caller treated the attestation as proof of erasure for a compliance report. Two facts surfaced later: - `R` had been materialized into an ancestor scope `org` by an indexing job that copied records upward for cross-team search. That copy carried the `org` retention policy, which had a 90-day floor. `forget(R)` deleted the `team/a` binding and returned an attestation; it did not touch the `org` materialization, because from `team/a`'s retention view that materialization was not addressable. - A second record `R'` was a *derived* summary of `R` (an embedding row keyed by a content hash). `forget(R)` did not cascade to `R'` because the derivation link was stored as a soft reference, not a foreign key. The attestation was cryptographically valid and operationally false. The record was readable in `org` for the remainder of the 90-day floor, and its embedding remained queryable by similarity. ## Condition of Validity A deletion attestation `att(R)` is valid only if, at the instant it is issued, all three of the following hold: 1. **Binding closure.** Every storage binding of `R` — primary, replica, upward materialization, cache tier — is marked deleted. "Binding" means any addressable location whose read path can return `R`'s bytes or a lossless function of them. 2. **Derivation closure.** Every record `R'` such that `R'` is a deterministic or near-deterministic function of `R` (embedding, summary, index entry, dedup hash) is either deleted or proven non-invertible to `R`'s content within the attestation's claimed strength. 3. **Scope-consistency of the claim.** The attestation's scope of assertion matches the scope of closure actually achieved. An attestation issued by `team/a` may only assert closure *within `team/a`'s retention view* unless the system has performed cross-scope closure and can name the scopes covered. If (3) fails but (1) and (2) hold, the attestation is *narrow but honest* — acceptable if the caller understands the narrowing. If (1) or (2) fails, the attestation is *false* regardless of how it is scoped. The dangerous case is (1)/(2) failing while (3) is silently widened: the caller reads the attestation as global. The validity condition is therefore not "the delete succeeded." It is "the delete succeeded *and* the retention graph reachable from the record has been closed to the depth the attestation claims." ## Decision Boundary The operational decision is: **when a `forget` call arrives, do you (A) attest immediately after deleting the caller-visible binding, or (B) hold the attestation until closure is established, possibly requiring deletion of records outside the caller's scope?** Choose (A) only when the attestation's scope is explicitly narrowed to the caller's retention view and the API contract states that narrowed attestations are not proof of global erasure. Choose (B) when the attestation is consumed by anything that treats it as global — compliance reports, data-subject requests, upstream caches that will drop their copies on the strength of it. The boundary is crossed by a specific, checkable signal: **does the record have any binding or derivation outside the deleting caller's retention scope?** If yes, (A) produces a false-global attestation and you must take (B) or narrow the claim. If no, (A) and (B) coincide and the distinction is moot. Two corollaries that are easy to get wrong: - **Closure is not transitive by default.** Deleting `R` does not delete `R'` unless the derivation link is a hard dependency. If derivation is soft, closure requires a separate traversal, and that traversal must run *before* the attestation clock starts. - **Retention floors can forbid closure.** If an ancestor scope holds `R` under a 90-day floor, closure is *impossible* until the floor expires. The correct behavior is to refuse the global attestation and return a narrowed one with the blocking scope named — not to delete the ancestor copy (which would violate the floor) and not to attest globally (which would be false). ## Operational Effect Systems that attest at (A) while claiming global scope exhibit a characteristic failure signature: attestations are issued in sub-second time, uniformly, regardless of record fan-out; downstream caches and replicas drop their copies on attestation strength; and the residual copies in ancestor scopes and derivation indexes are discovered only by a later audit, at which point the attestation is already in a compliance record and cannot be retracted. Systems that attest at (B) show the opposite signature: attestation latency scales with fan-out and derivation depth; some `forget` calls return a *narrowed* attestation naming a blocking scope and a floor expiry; and a nonzero fraction of calls return "closure pending" rather than an attestation. That last outcome is correct and must be representable in the API — a `forget` that cannot yet be attested must be able to say so without pretending success. The invariant to enforce: **no attestation is issued for a record whose retention-scope closure is not established to the depth the attestation claims.** Encode the claim's depth in the attestation itself, and make the closure check a precondition of signing, not a postcondition of logging. ```python import hashlib from dataclasses import dataclass, field class AttestationScopeError(RuntimeError): """Raised when an attestation would claim more closure than was achieved.""" pass class RetentionFloorViolation(RuntimeError): """Raised when closure would require deleting a record under a retention floor.""" pass @dataclass class Binding: scope: str retention_floor_days: int deleted: bool = False @dataclass class Record: record_id: str bindings: list[Binding] = field(default_factory=list) derivations: list[str] = field(default_factory=list) # soft links to derived record ids def _closure_depth(record: Record, store: dict[str, Record], seen: set[str]) -> tuple[int, list[str]]: """Return (achieved_depth, blocking_scopes). Depth 0 = no closure, 1 = caller scope only, 2 = all bindings + derivations.""" if record.record_id in seen: return 2, [] seen.add(record.record_id) blocking: list[str] = [] depth = 2 for b in record.bindings: if not b.deleted: if b.retention_floor_days > 0: blocking.append(b.scope) depth = min(depth, 1) else: depth = min(depth, 1) for d_id in record.derivations: if d_id in store: sub_depth, sub_block = _closure_depth(store[d_id], store, seen) depth = min(depth, sub_depth) blocking.extend(sub_block) return depth, blocking def forget( record_id: str, caller_scope: str, store: dict[str, Record], claimed_depth: int = 2, ) -> dict: """Delete caller-visible binding and issue an attestation only if closure meets claimed_depth. claimed_depth: 1 = assert closure within caller scope only; 2 = assert global closure (all bindings + derivations). Defaults to the strict claim. """ record = store.get(record_id) if record is None: raise AttestationScopeError(f"record {record_id} not addressable from {caller_scope}") # Delete the caller-visible binding first (the part we are allowed to do unconditionally). for b in record.bindings: if b.scope == caller_scope: b.deleted = True achieved_depth, blocking_scopes = _closure_depth(record, store, set()) if claimed_depth == 2 and achieved_depth < 2: if blocking_scopes: raise RetentionFloorViolation( f"cannot attest global closure for {record_id}: " f"retention floors block deletion in scopes {sorted(set(blocking_scopes))}" ) raise AttestationScopeError( f"cannot attest global closure for {record_id}: " f"achieved depth {achieved_depth} < claimed {claimed_depth}; " f"undeleted bindings or derivations remain" ) if claimed_depth > achieved_depth: raise AttestationScopeError( f"attestation claim depth {claimed_depth} exceeds achieved {achieved_depth} for {record_id}" ) payload = f"{record_id}|{caller_scope}|depth={achieved_depth}" return { "record_id": record_id, "scope": caller_scope, "claimed_depth": claimed_depth, "achieved_depth": achieved_depth, "blocking_scopes": sorted(set(blocking_scopes)), "digest": hashlib.sha256(payload.encode()).hexdigest(), } ```