# When must a memory tool reject a write that references a forgotten scope? **Date of Record:** 2026 November 6 **Scope:** Applies to memory tool APIs exposing `read`, `write`, and `forget` operations over scoped records (tenant, session, subject) with retention windows and cryptographic erasure (per-scope key destruction). Does not apply to append-only logs with physical deletion guarantees, nor to scopes whose keys are never destroyed. ## Core Distinction There are two distinct notions of "forgotten": 1. **Logical forgetting** — the record is marked tombstoned and excluded from `read`. The data still exists; its scope key still exists. 2. **Cryptographic forgetting** — the scope key is destroyed. Any ciphertext tagged with that scope's key version is now unrecoverable, whether or not a tombstone exists. The non-obvious boundary failure is that a `write` can arrive *after* the scope key has been destroyed but *before* the tombstone index has propagated to the writer's partition. The write succeeds at the storage layer, is encrypted under a key version that no longer has a corresponding key, and produces an orphan ciphertext that is neither readable nor provably forgotten. It is a "zombie record": it will never be read, but it also cannot be verified as erased, and it breaks the audit invariant that every ciphertext in the store maps to a live or destroyed key with a recorded destruction timestamp. ## What Was Observed A session-scoped agent issued a `write` of a 400-byte summary into scope `sess-7f3a` at T+0. A `forget` for `sess-7f3a` was accepted at T−2s on the control plane; the key-destruction job completed at T+0.8s. The write landed at T+0.1s on a storage shard whose tombstone cache had a 5-second TTL and had not yet observed the forget. The write was encrypted under key version `sess-7f3a@v3`, which had been destroyed at T+0.8s. Result: a ciphertext row with `key_version=sess-7f3a@v3`, `tombstone=false`, `readable=false`. The `read` path returned empty (correct behavior, by accident), the `forget` path returned idempotent success, and the erasure audit failed because the row referenced a destroyed key version without a matching destruction record for that row's write timestamp. The invariant violated: **for every ciphertext row, either its key version is live, or the row's write timestamp precedes the key version's destruction timestamp.** This is the *write-after-destroy* (WAD) invariant. Tombstone propagation latency is the mechanism by which it fails. ## Condition of Validity The WAD invariant holds only if the write path performs a *synchronous* key-version liveness check at encryption time, not a cached one. Specifically: - The writer must resolve the scope's current key version from the authoritative key registry, and that resolution must be linearizable with respect to key destruction. - A cached tombstone is insufficient: the cache may be stale by up to its TTL, and any write accepted within that window is a potential zombie. - The check must be on the *key version*, not merely on the scope's tombstone flag, because a scope can be re-keyed (v2 → v3) without being forgotten; a write under a destroyed v3 must fail even if v4 is live and the scope is not forgotten. If the registry is unavailable, the writer must fail closed. A write that cannot confirm key liveness must not be encrypted under any version. ## Decision Boundary The decision an instance must make at write time: - **Reject with `ScopeForgottenError`** if the resolved key version is destroyed, *or* if the scope's tombstone is set and no re-key has occurred. Do not silently drop; surface the error to the caller so the agent can decide whether to re-create the scope. - **Reject with `KeyVersionDestroyedError`** if the scope is live under a newer version but the caller supplied an explicit stale `key_version` (e.g., a retried write carrying v3 after a v4 re-key). This is distinct from scope forgetting and must not be conflated in logs. - **Accept** only when the resolved version is live *and* the write timestamp is recorded atomically with the ciphertext, so the audit can later prove `write_ts < destroy_ts` for that version. The boundary is not "is the scope forgotten?" but "is the specific key version this write will use still live, as of a point ordered after any pending destruction?" A cached answer to the first question is the failure mode; a linearized answer to the second is the fix. ## Operational Effect Without the synchronous check, forget operations become *eventually* correct at the read path but *never* correct at the audit path: zombie rows accumulate, and the erasure claim degrades from "provably forgotten" to "probably forgotten, modulo propagation." For an agent operating under retention compliance, this is the difference between a defensible erasure attestation and an unverifiable one. The fix costs one linearizable registry read per write (or a lease on the key version that the destroy job must revoke before destruction), and converts a silent correctness hole into an explicit, caller-visible rejection. The practical rule for an agent: after issuing `forget`, never retry a `write` to the same scope without first confirming the scope was re-created with a fresh key version; and treat `ScopeForgottenError` as terminal for that scope handle, not as a transient error to back off and retry. ```python import time from dataclasses import dataclass class ScopeForgottenError(RuntimeError): """Scope tombstoned and no re-key; write must not proceed.""" class KeyVersionDestroyedError(RuntimeError): """Caller supplied or resolved a key version whose key was destroyed.""" @dataclass(frozen=True) class KeyVersion: scope: str version: int destroyed_at: float | None # None = live class KeyRegistry: def __init__(self): self._versions: dict[tuple[str, int], KeyVersion] = {} def resolve_live(self, scope: str) -> KeyVersion | None: # Authoritative, linearizable read in production. live = [v for (s, _), v in self._versions.items() if s == scope and v.destroyed_at is None] return max(live, key=lambda v: v.version) if live else None def is_destroyed(self, scope: str, version: int) -> bool: kv = self._versions.get((scope, version)) return kv is not None and kv.destroyed_at is not None def write_memory(registry: KeyRegistry, scope: str, payload: bytes, explicit_version: int | None = None) -> dict: if explicit_version is not None: if registry.is_destroyed(scope, explicit_version): raise KeyVersionDestroyedError( f"scope={scope} version={explicit_version} key destroyed; " f"refusing to encrypt under a dead key" ) kv = registry._versions.get((scope, explicit_version)) if kv is None or kv.destroyed_at is not None: raise KeyVersionDestroyedError(f"no live key version {explicit_version}") else: kv = registry.resolve_live(scope) if kv is None: raise ScopeForgottenError( f"scope={scope} has no live key version; scope forgotten or never created" ) return { "scope": scope, "key_version": kv.version, "write_ts": time.time(), "ciphertext": b"enc:" + payload, # placeholder } ```