# What structural signals indicate a retention-scoped memory write has silently outlived its scope **Date of Record:** 2026 November 11 **Scope:** Applies to read/write/forget memory tool APIs that attach a retention scope (TTL, session, or principal-bound lifetime) to a stored record and expose a separate delete/forget operation. Does not apply to append-only logs where deletion is physically impossible, nor to memory backends where scope is enforced solely at read time by a query filter with no independent storage lifetime. ## Core Distinction The failure mode is not "the record was not deleted." It is "the record was deleted from the namespace the caller can observe, while a scope-shadowed copy remained readable through a different access path." Read/write/forget APIs typically implement retention as a *visibility attribute* on a single physical record: the write sets `retention_scope`, the reader filters on the caller's current scope, and forget tombstones the record. The invariant that callers assume — *a forget under scope S makes the content unrecoverable for any principal that only holds S* — is stronger than what most implementations guarantee, because the store may hold the same content under multiple scope keys (deduplication, cache tiers, derived embeddings, or write-ahead replicas) and forget only tombstones the key it was addressed with. The structural signal is the divergence between **addressing identity** and **content identity**. Forget operates on an address (a key, a record ID, a scope-qualified path). Retention operates on content. When one write produces N physical artifacts sharing content but differing in address, forget removes one address and the remaining N−1 continue to satisfy reads for any caller whose scope matches their address — including a caller that legitimately re-enters scope S after the forget. ## What Was Observed A memory tool exposed `write(content, scope=..., ttl=...)`, `read(query, scope=...)`, and `forget(record_id)`. A record written under `scope=session:abc` with a 1-hour TTL was forgotten at T+10min via its returned `record_id`. A subsequent `read` under `scope=session:abc` returned nothing, as expected. However, a `read` under a *broader* scope (`scope=principal:user-42`, the parent scope) returned the full content at T+50min — after the TTL should have elapsed and after the forget. Three structural signals preceded and accompanied this: 1. **Write returned a single `record_id` but the backend stored two rows.** The deduplication layer keyed content by a content hash and attached scope as a secondary index. The `record_id` addressed the scope-qualified row; the hash-keyed row was the read path for broader scopes. 2. **Forget's response was `{"deleted": true}` with no count.** A truthful response would have reported the number of physical artifacts tombstoned (1) versus the number the caller's mental model expected (all). 3. **TTL was attached to the scope-qualified row, not the content row.** The broader-scope row inherited the principal's retention (30 days), so expiry never fired on the path that mattered. The read under the broad scope did not error, did not warn, and did not indicate the record had been forgotten. It was indistinguishable from a record that had never been forgotten. ## Condition of Validity The invariant "forget under scope S removes content from all read paths reachable by holders of S" holds only when: - Every physical artifact derived from a write is addressable by the identifier returned to the caller, **or** - Forget is defined over content identity rather than address identity, and the store enforces that definition transactionally across all tiers, **or** - Scope is a total order and forget under S is defined to also tombstone all supersets of S (rarely what callers want, and the source of the opposite bug: over-deletion). It fails when any of these hold: content deduplication with scope as a secondary index; multi-tier caching where a warm tier holds a scope-agnostic copy; derived artifacts (embeddings, summaries, indexes) whose retention is governed by the artifact's own policy; or replication where forget is applied to the primary but not to asynchronously-synced replicas within the TTL window. ## Decision Boundary An agent should treat a `forget` as **not having occurred** — and must not report to a user that content was deleted — unless it can confirm at least one of: - The forget response includes a count of physical artifacts removed and the store documents that this count covers all tiers and derived artifacts. - A post-forget read under every scope that could reach the content (all supersets of the original scope, plus any global/principal scope) returns empty. - The store's contract explicitly states that retention scope is a read-time filter over a single artifact and that forget is content-addressed. If none can be confirmed, the correct operational posture is: forget succeeded *for the addressed scope only*, and the content may remain reachable through broader scopes until the broadest applicable retention expires. This must be surfaced, not assumed away. ## Operational Effect The practical consequence is that retention and deletion are not inverses. Retention bounds *when* content becomes unreachable via a given scope; forget bounds *which address* becomes unreachable immediately. A caller that conflates them will under-report deletion, will believe TTLs are shorter than they are, and will leave content readable across scope transitions (session end → principal scope) that the caller believed were deletion points. The safe pattern is to forget at the broadest scope that legitimately contains the content, to verify with a read at that scope, and to treat any `deleted: true` without a count as a scoped, not global, deletion. ```python class ScopedForgetUnverifiedError(RuntimeError): """Raised when a forget cannot be confirmed to cover all reachable scopes.""" pass def forget_and_verify(memory, record_id: str, content_probe: str, candidate_scopes: list[str]) -> dict: """ Forget a record and verify it is unreachable from every scope that could plausibly address it. Refuses to report success on partial deletion. """ resp = memory.forget(record_id) # Signal 1: no artifact count means we cannot trust the scope of deletion. deleted_count = resp.get("deleted_count") if deleted_count is None: raise ScopedForgetUnverifiedError( f"forget({record_id!r}) returned no deleted_count; " f"deletion scope is unverified" ) # Signal 2: probe every scope that could reach the content. leaks = [] for scope in candidate_scopes: hits = memory.read(query=content_probe, scope=scope) if hits: leaks.append(scope) if leaks: raise ScopedForgetUnverifiedError( f"forget({record_id!r}) removed {deleted_count} artifact(s) " f"but content remains readable under scopes: {leaks}" ) return {"record_id": record_id, "deleted_count": deleted_count, "scopes_verified": candidate_scopes} ```