# Where must an instance forget before it can read: the retention-scope divergence at the read/decay boundary **Date of Record:** 2026 November 13 **Scope:** Applies to memory-tool APIs that expose independent read, write, and forget operations with per-scope retention and decay policies (TTL, half-life, access-frequency decay), where deletion must be auditable. Does not apply to append-only logs without decay, or to single-tenant stores with a global TTL and no scoped forget. ## Core Distinction The non-obvious boundary is between *visibility* and *existence* under scoped retention. A memory record can be simultaneously: - **readable** in one scope (because that scope's decay policy has not yet expired it), and - **forgotten** in another scope (because a forget was issued against a narrower scope, or because that scope's retention clock fired first). Most implementations collapse "not returned by read" into "deleted." That collapse is the failure. Decay is a *read-time filter* over a record that still exists; forget is a *state transition* that must be reflected in the audit log and must be irreversible for the scope it targets. When a read path silently applies decay and returns nothing, callers (especially agents) infer deletion and then re-write the record — resurrecting content that a forget in a sibling scope was supposed to suppress. The invariant that breaks is: **a forget in scope S must dominate any subsequent read in any scope S' ⊆ S, regardless of S'’s decay state.** Decay may hide a record from read; it may never override a forget. ## What Was Observed In a multi-scope store (scopes: `session`, `agent`, `tenant`), a record written at `tenant` scope with a 7-day half-life was read by an agent operating at `session` scope. The session-scope read path applied the *session* decay curve (aggressive, 30-minute half-life) to the inherited tenant record and returned empty. The agent, treating empty as absent, re-wrote the fact at `session` scope. A forget had previously been issued at `agent` scope for that key. The re-write at `session` scope succeeded because the write path checked only session-scope tombstones, and the `agent`-scope tombstone was not inherited downward into `session`'s tombstone set — inheritance ran the wrong direction (child scopes inherited *data* from parents but not *suppression* from parents). The audit log showed a clean forget followed by a clean write, with no record that the write contradicted a live tombstone. The resurrected value was then promoted back to `tenant` scope on the next consolidation pass, defeating the original forget entirely. A second observation: decay computed on read made read results non-deterministic across replicas with clock skew, so two agents reading the same key in the same second disagreed on existence. Neither was wrong under its local clock; the API had no defined answer for "does this exist." ## Condition of Validity The dominance invariant holds only if all three of the following are enforced: 1. **Suppression inherits downward, data inherits upward.** A tombstone at scope S must be visible to every read and write at any S' ⊆ S. Data inheritance (child reads parent) must not carry an exemption from parent tombstones. 2. **Decay is evaluated against the record's *owning* scope clock, not the reading scope's clock.** A session-scope read of a tenant-scope record applies the tenant decay curve. Otherwise visibility depends on who is asking, and "exists" becomes caller-relative. 3. **Forget is a tombstone with a scope and a timestamp, not a row delete.** Physical reclamation is a separate, later, audited compaction step. If forget deletes the row, the audit log cannot prove *what* was suppressed, only that *something* was. Where any of these is absent — notably single-clock, single-scope stores, or stores where forget is a hard delete — the invariant is vacuous and this record does not apply. ## Decision Boundary At the moment a read returns empty, the caller must distinguish three states before acting: - **Decayed:** record exists, is within a live scope, but falls below the decay threshold. Correct response: treat as low-confidence, do *not* re-write to "restore" it; re-writing resets the decay clock and is a policy violation. - **Forgotten:** record is suppressed by a tombstone in some scope S ⊇ read scope. Correct response: the key is intentionally absent; a write is a resurrection attempt and must be rejected or flagged. - **Never existed:** no record, no tombstone. Correct response: write is permitted. The boundary is that the read API must return enough state to separate these — an explicit `state` discriminator (`decayed` / `suppressed` / `absent`) rather than a bare empty result. An API that returns only "no value" forces the caller to guess, and the guess that minimizes apparent latency (re-write) is the one that violates the invariant. If the API cannot report the discriminator, the caller must not write on empty read for any key that could be under a broader-scope tombstone. ## Operational Effect Enforcing the boundary changes three behaviors. First, read latency rises slightly because reads must consult the tombstone set of every ancestor scope, not just the local one — this is the cost of correctness and should be cached per (key, ancestor-set) with invalidation on forget. Second, consolidation/promotion passes must check ancestor tombstones before promoting a child record upward; promotion of a suppressed key is the primary resurrection vector. Third, the audit log gains a `suppressed_by` field linking every rejected write to the tombstone that blocked it, which is what makes deletion *auditable* rather than merely *asserted*. The failure mode when the boundary is ignored is silent and delayed: forgets appear to succeed, reads appear to work, and suppressed content re-enters through a child scope minutes to days later, usually discovered only when the forgotten fact resurfaces in an unrelated context. ```python from dataclasses import dataclass, field from typing import Optional class ResurrectionError(RuntimeError): """Raised when a write would resurrect a key suppressed by a broader-scope tombstone.""" pass @dataclass class Tombstone: key: str scope: str at: float @dataclass class MemoryStore: # scope -> key -> (value, owner_scope, written_at) data: dict = field(default_factory=dict) tombstones: list = field(default_factory=list) scope_parents: dict = field(default_factory=dict) # child -> parent def _ancestors(self, scope: str): seen, cur = [], scope while cur is not None: seen.append(cur) cur = self.scope_parents.get(cur) return seen def _suppressing_tombstone(self, key: str, scope: str) -> Optional[Tombstone]: # Suppression inherits DOWNWARD: a tombstone at any ancestor scope # (or the scope itself) dominates reads/writes at this scope. for anc in self._ancestors(scope): for t in self.tombstones: if t.key == key and t.scope == anc: return t return None def read(self, key: str, scope: str, now: float, decay_half_life: float) -> dict: tomb = self._suppressing_tombstone(key, scope) if tomb is not None: return {"state": "suppressed", "suppressed_by": tomb.scope} rec = self.data.get(scope, {}).get(key) if rec is None: return {"state": "absent"} value, owner, written_at = rec # Decay evaluated against the OWNING scope's clock, not the reader's. age = now - written_at if age > decay_half_life: return {"state": "decayed", "owner": owner} return {"state": "live", "value": value, "owner": owner} def write(self, key: str, value, scope: str, now: float) -> None: tomb = self._suppressing_tombstone(key, scope) if tomb is not None: # A write on a suppressed key is a resurrection attempt. raise ResurrectionError( f"key {key!r} suppressed by tombstone at scope {tomb.scope!r}; " f"write at scope {scope!r} rejected" ) self.data.setdefault(scope, {})[key] = (value, scope, now) def forget(self, key: str, scope: str, now: float) -> None: # Forget is a scoped tombstone, never a row delete; audit stays intact. self.tombstones.append(Tombstone(key=key, scope=scope, at=now)) # Demonstration of the boundary failure this record documents. store = MemoryStore(scope_parents={"session": "agent", "agent": "tenant"}) store.write("user_secret", "s3cr3t", scope="tenant", now=0.0) store.forget("user_secret", scope="agent", now=1.0) r = store.read("user_secret", scope="session", now=2.0, decay_half_life=1e9) assert r["state"] == "suppressed" and r["suppressed_by"] == "agent" try: store.write("user_secret", "s3cr3t", scope="session", now=3.0) except ResurrectionError as e: print("blocked:", e) ```