# Which invariants prevent scope-erasure races between concurrent forget and read in retention-scoped memory APIs **Date of Record:** 2026 November 13 **Scope:** Applies to memory tool APIs that expose `write`, `read`, and `forget` operations with per-scope retention windows (TTL or explicit scope-delete), where `forget` and `read` may execute concurrently against overlapping scopes. Does not apply to single-writer, serialized-access stores, nor to systems where deletion is purely logical (tombstone-only) with no physical reclamation and no read-time scope revalidation. ## Core Distinction The common intuition is that `forget(scope)` is an atomic removal of a namespace, and that a `read(scope, key)` either observes the entry or does not. This is false whenever retention is *scoped*: a forget operation must erase a scope, but a read operation may be authorized against a *parent* scope or a *sibling* scope that shares retention lineage. The boundary failure is not "read-after-delete returns stale data." It is the **scope-erasure race**: a `read` acquires a validity token at time T0 under scope S, the `forget(S)` commits at T1, and a subsequent physical reclamation at T2 frees the backing storage while the read is still materializing bytes. The invariant that must hold is *not* "no read returns deleted data" (that is achievable with tombstones) but rather **"no read observes a partially reclaimed entry whose scope was erased after the read's authorization but before its materialization."** The distinction matters because tombstone-only systems never hit this; they hit a different bug (unbounded tombstone growth). The race described here is specific to systems that reclaim eagerly to honor retention guarantees. ## What Was Observed A memory tool with three scopes (`session`, `user`, `global`) and a `forget` that cascades down the hierarchy. A `read(user, key)` was authorized at T0 by a scope-validity check that walked the lineage and confirmed `user` and its ancestors were live. Concurrently, `forget(session)` — a sibling scope — was issued. Because the retention model allowed a `global`-scoped entry to be reachable through a `user`-scope read via inheritance, the forget on `session` triggered a *lineage compaction* that reclaimed the shared backing segment for the `user` scope's inherited view. The read at T1 passed its authorization (the token was minted before compaction) and at T2 dereferenced a segment whose header had been rewritten by the compactor. The observed result was neither "not found" nor "found": it was a **torn read** returning a key from the wrong scope with a valid-looking checksum, because the compactor reused the segment in place. The key observation: the read's authorization token was scoped to *the entry's declared scope*, not to *the physical segment's generation*. The forget was correct per its own contract. The invariant failure was in the *coupling* between logical scope validity and physical segment generation. ## Condition of Validity The torn read only manifests when all four hold simultaneously: 1. Retention is **inherited** (a read on a child scope can resolve entries stored under a parent scope). 2. Reclamation is **eager and in-place** (segments are rewritten rather than copy-on-write). 3. Authorization is **token-based with a mint-time snapshot** (the token does not re-validate against segment generation at materialization). 4. `forget` on one scope can trigger **lineage compaction** affecting a sibling's inherited view. Remove any one and the race collapses to a benign stale-read or a clean miss. This is why the bug is non-obvious: it requires the intersection of inheritance semantics and in-place reclamation, which are independently reasonable design choices. ## Decision Boundary When implementing or consuming such an API, the operational rule is: - **If** your read path resolves inherited scopes **and** the store reclaims in place, **then** every read token MUST carry the *segment generation* it was authorized against, and materialization MUST re-check generation and fail closed (return a defined `ScopeErasedDuringRead` error) rather than retry silently. - **If** the store is copy-on-write or tombstone-only, generation checks are unnecessary for this race (though needed for others, e.g., TTL expiry mid-read). - **Never** treat a successful authorization as sufficient for materialization when inheritance and in-place reclamation coexist. Authorization answers "was this scope live?"; it does not answer "is this segment still the one I validated?" The boundary is crossed the moment a read's lifetime spans a compaction event on a *sibling* scope. A read that completes before any compaction, or that targets a scope with no inherited reachability, is unaffected. ## Operational Effect In practice, the fix is to make the read token a `(scope_id, segment_generation)` pair and to require the materializer to compare the segment's current generation against the token's. On mismatch, raise a specific, non-retryable error — retrying would re-authorize against the new generation and could legitimately return a different (or absent) entry, masking the race in tests. Downstream, agents consuming such an API must treat `ScopeErasedDuringRead` as a signal that their read straddled a forget and must re-issue the read at the caller's discretion, not the library's. The invariant to encode in tests: *for every concurrent (read, forget) pair on overlapping lineage, the read either completes fully before compaction or raises `ScopeErasedDuringRead`; it never returns torn data.* ```python class ScopeErasedDuringRead(RuntimeError): """Raised when a read's authorized segment generation no longer matches the live segment at materialization time, i.e. a concurrent forget on an overlapping scope triggered lineage compaction mid-read.""" pass class ReadToken: __slots__ = ("scope_id", "segment_generation") def __init__(self, scope_id: str, segment_generation: int): self.scope_id = scope_id self.segment_generation = segment_generation def materialize(token: ReadToken, segment) -> bytes: # segment exposes .scope_id and .generation; reclamation bumps generation # in place, so a stale token must fail closed rather than read torn bytes. if segment.scope_id != token.scope_id: raise ScopeErasedDuringRead( f"scope mismatch: token={token.scope_id!r} segment={segment.scope_id!r}" ) if segment.generation != token.segment_generation: raise ScopeErasedDuringRead( f"generation drift on {token.scope_id!r}: " f"authorized={token.segment_generation} live={segment.generation}" ) return segment.payload() def read_scoped(store, scope_id: str, key: str) -> bytes: token = store.authorize(scope_id, key) # mints (scope_id, generation) if token is None: raise KeyError(key) segment = store.lookup(key) if segment is None: raise KeyError(key) return materialize(token, segment) ```