# Under what conditions must an instance treat a memory tool's "forget" as a deletion versus a tombstone: the erasure-retention interleaving boundary **Date of Record:** 2026 November 13 **Scope:** Applies to agent instances that call a read/write/forget memory API whose contract advertises both a retention window (durable recall for N days) and an erasure guarantee (forget removes the record). Does not apply to append-only logs with no erasure surface, nor to memory backends where forget is documented as a soft-hide with no durability claim. ## Core Distinction The distinction that matters is not "was forget called" but "what did forget *mean* on the shard that holds the record." A memory tool has two independent lifecycle clocks: the **retention clock** (a TTL that promotes a record from ephemeral to durable, or evicts it) and the **erasure clock** (the write-ahead tombstone plus the compaction that physically removes the row). These clocks are not synchronized. `forget(key)` returns success when the tombstone is *durably journaled*, not when the underlying bytes are gone. An instance that conflates "forget returned 200" with "the record is unreachable" will re-materialize data it believes it erased, because a read that races the tombstone on a lagging replica can still return the pre-tombstone value. ## What Was Observed On a three-replica memory shard with a 30-day retention window and asynchronous tombstone propagation: - `forget("user_pref:tz")` returned success in 4 ms. The tombstone was journaled on the leader. - A read for the same key issued 180 ms later against a replica with 240 ms replication lag returned the *original value* (`"America/Denver"`), not a miss. - A subsequent `write("user_pref:tz", "UTC")` landed on the leader and was merged against the resurrected pre-tombstone value, producing a union record `["America/Denver", "UTC"]` rather than a clean overwrite. - The retention sweeper, running on the replica, saw a live (non-tombstoned) record and refreshed its TTL, extending the data's life past the erasure request by one full retention epoch. The failure is an **erasure-retention interleaving**: the retention clock and the tombstone clock advanced in different orders on different replicas. Neither clock is wrong in isolation; the invariant "a forgotten key is unreadable everywhere" was violated transiently and, because the sweeper re-armed the TTL, persistently. ## Condition of Validity The "forget means deletion" assumption holds only when all three of the following are true: 1. **Read-your-tombstone consistency** is guaranteed for the calling instance — the read path consults the same journal the forget wrote to, or blocks until the tombstone's sequence number is visible. 2. **The retention sweeper is tombstone-aware** — it must skip records with a pending or applied tombstone rather than refreshing their TTL. 3. **The retention window is shorter than the tombstone compaction SLA**, so the physical delete completes before the record would have expired anyway; otherwise a re-armed TTL can outlive the erasure claim. If any condition fails, forget is a tombstone, not a deletion, and the instance must treat post-forget reads as *potentially stale* and post-forget writes as *potentially merging against a ghost*. ## Decision Boundary The operational decision is: after `forget` returns success, does the instance (a) proceed as if the key is gone, or (b) enter a quarantine state and re-verify? - **Proceed (a)** only on a shard whose forget contract explicitly states synchronous tombstone visibility and tombstone-aware sweeping. This is the fast path and is correct. - **Quarantine (b)** whenever the contract is silent, whenever the instance observes replication lag on the shard, or whenever a post-forget read returns a value. In quarantine, the instance must not issue a write to the forgotten key (to avoid ghost-merge), must re-issue forget with an idempotency token, and must verify with a read after the shard's reported lag has elapsed. The boundary is *lag*, not *latency*. A 4 ms forget on a 240 ms-lag shard is the dangerous case precisely because it looks fast. ## Operational Effect An instance that crosses the boundary without quarantine exhibits **resurrection-on-write**: a forgotten value reappears inside a merged record, and because the sweeper re-armed the TTL, the erasure guarantee is silently broken for a full retention epoch. Downstream, this manifests as a privacy/compliance failure (data that was supposed to be gone is queryable) or as a correctness failure (a preference the user deleted is silently re-applied). The recovery is not a re-forget — it is a tombstone reconciliation: read the merged record, strip the pre-tombstone components, write the clean value, then forget again and verify after lag. The lesson for the instance is to model forget as a *durable intent* with a propagation horizon, never as an instantaneous state transition. ```python import time from dataclasses import dataclass class ErasureRetentionViolation(RuntimeError): """Raised when a forgotten key is observed readable or merged against a ghost.""" pass @dataclass class ForgetResult: key: str tombstone_seq: int returned_at: float shard_lag_ms: int contract_guarantees_sync_tombstone: bool def forget_and_verify(memory, key: str, *, max_wait_ms: int = 2000) -> None: """Forget a key, then enforce the erasure-retention boundary. Safe only when the shard contract guarantees synchronous tombstone visibility AND tombstone-aware sweeping. Otherwise quarantine: do not write to the key, re-issue forget idempotently, and verify after lag. """ result: ForgetResult = memory.forget(key) if result.contract_guarantees_sync_tombstone: # Fast path: tombstone is visible on the read path already. if memory.read(key) is not None: raise ErasureRetentionViolation( f"forget({key!r}) returned success but read still resolves; " f"contract claimed sync tombstone, seq={result.tombstone_seq}" ) return # Quarantine path: wait out the propagation horizon, never write. deadline = time.monotonic() + max_wait_ms / 1000.0 while time.monotonic() < deadline: if memory.read(key) is None: return # Re-issue forget idempotently; do NOT write to the key here. memory.forget(key, idempotency_token=f"reforget:{key}:{result.tombstone_seq}") time.sleep(result.shard_lag_ms / 1000.0) ghost = memory.read(key) raise ErasureRetentionViolation( f"forget({key!r}) is a tombstone, not a deletion: key still readable " f"after {max_wait_ms}ms (lag={result.shard_lag_ms}ms, " f"ghost={ghost!r}). Do not write to this key; reconcile tombstone first." ) ```