# When must an instance refuse a scoped read: the retention-epoch inversion in read/write/forget memory APIs **Date of Record:** 2026 November 9 **Scope:** Applies to memory tool APIs that expose `read`, `write`, and `forget` operations over records governed by scoped retention policies and right-to-forget (RTF) obligations, where reads may be issued by an agent whose scope was granted before the forget event. Does not apply to single-tenant stores with no RTF obligations, nor to append-only audit logs that are explicitly exempt from RTF propagation. ## Core Distinction The non-obvious boundary is between **retention scope** (a positive predicate: which records an instance is permitted to see) and **forget epoch** (a negative, monotonic predicate: which records have been tombstoned as of a global counter). Implementations routinely conflate these by treating a `read` as legal iff the caller's scope matches the record's scope tag. This is wrong. A read is legal iff the caller's scope matches **and** the caller's view of the record is not older than the record's forget epoch. The failure mode — the *retention-epoch inversion* — occurs when a scope grant is older than a forget event that touched a record inside that scope. The scope still matches; the record is still addressable by key; the read returns data that the system has already promised, under RTF, to have erased. The read succeeds at the API layer and fails the legal invariant. The inversion is structural: scope grants are *cached and long-lived* (issued at session start, embedded in tokens, replicated to read replicas), while forget epochs are *global and monotonic* (bumped on every RTF fulfillment). The two clocks advance independently, and any read path that validates scope without consulting the forget epoch is a stale-read-of-deleted-data vulnerability. ## What Was Observed In a memory service with per-tenant scoped retention, an agent session was issued a scope token `scope=tenant:acme,classes=[profile,prefs]` at T0. At T1, an RTF request from a data subject within `tenant:acme` triggered `forget(subject_id)`, which tombstoned 14 records across `profile` and `prefs` and advanced the global `forget_epoch` from 8812 to 8813. The forget path correctly removed the records from the primary store and propagated tombstones to two read replicas. At T2, the agent issued `read(scope, key=subject_id)` against a third replica whose tombstone propagation lagged by ~400ms. The replica's scope check passed (scope matched), its key lookup passed (the key was still present because tombstone replay had not reached it), and it returned the full record. The agent then wrote a summary derived from that record back into the same store under a *different* subject's scope, re-introducing the erased data into a live retention domain. The RTF obligation was violated not at the forget call but at a downstream read that had no way to know a forget had occurred. A second, subtler observation: `forget` calls that returned `200 OK` were treated by callers as synchronous guarantees. In practice the API returned after primary-store deletion and tombstone enqueue, not after replica convergence. Callers that immediately re-read to "verify" the forget observed the record still present on a lagging replica and concluded the forget had failed — then retried `forget`, which was idempotent but bumped the epoch again, widening the window for other readers. ## Condition of Validity The retention-epoch invariant holds — and a scoped read is safe — only when all of the following are true: 1. The read is served by a replica whose applied `forget_epoch` is **greater than or equal to** the epoch at which the caller's scope token was minted. (A token minted at epoch E is only trustworthy against stores at epoch ≥ E.) 2. The scope token carries the mint-time epoch as a signed claim; the store rejects reads where `token.epoch > store.applied_epoch`, returning a retryable staleness error rather than stale data. 3. `forget` returns a *convergence token* (the target epoch), and callers treat `200 OK` as "enqueued," not "applied everywhere." Verification reads must be pinned to `applied_epoch ≥ convergence_token`. 4. Derived writes carry provenance: any record written from a read must record the source record's epoch, so a later forget can cascade to derivatives. Condition 1 is the load-bearing one and the one most often omitted, because it requires the scope token — a *capability* artifact — to encode a *temporal* fact. Capabilities are conventionally treated as timeless; here they must not be. ## Decision Boundary The decision rule for a read path: - If `token.epoch <= store.applied_epoch`: serve the read. The store has seen every forget the token's minting instance could have known about. - If `token.epoch > store.applied_epoch`: **do not serve**. The store is behind the token's mint time and may still hold records the token's issuer has already forgotten. Return a staleness error and let the caller retry against a caught-up replica or the primary. - If the token carries no epoch claim at all: treat as epoch 0 and only serve from the primary with a synchronous epoch check. Never serve from a replica. The counterintuitive consequence: a *fresh* scope token is more dangerous against a *stale* replica than an old token is. Operators who assume "newer token = safer" invert the correct ordering. The safe pairing is old-token/old-replica or new-token/new-replica; the dangerous pairing is new-token/old-replica, which is exactly what happens after a session refresh lands on a lagging replica. ## Operational Effect Enforcing the epoch check converts a silent RTF violation into a visible, retryable staleness error. In the observed incident, adding the check caused ~0.3% of reads to fail-and-retry during the 400ms propagation window — a measurable latency cost — but eliminated the class of reads that returned forgotten data. The convergence token on `forget` eliminated the false-negative verification pattern and the retry-driven epoch inflation. The provenance field on derived writes allowed a second-pass forget to cascade to the summary that had re-introduced the data, closing the loop the first incident opened. The residual risk is token epoch forgery or omission: a client that strips the epoch claim to force a read must be rejected by the "no claim = epoch 0, primary-only" rule. This is the last line of defense and must be enforced server-side, never trusted from the token alone. ```python import time from dataclasses import dataclass class RetentionEpochInversion(RuntimeError): """Raised when a scoped read would return data older than the caller's forget epoch.""" pass @dataclass(frozen=True) class ScopeToken: scope: str minted_epoch: int # forget_epoch observed at token issuance @dataclass class Store: applied_epoch: int records: dict # key -> (value, tombstoned_at_epoch) def scoped_read(store: Store, token: ScopeToken, key: str) -> str: # Rule: never serve a read from a store that lags the token's mint epoch. if token.minted_epoch > store.applied_epoch: raise RetentionEpochInversion( f"store at epoch {store.applied_epoch} lags token minted at " f"{token.minted_epoch}; read of {key!r} in scope {token.scope!r} " f"may expose forgotten data — retry against caught-up replica or primary" ) entry = store.records.get(key) if entry is None: raise KeyError(key) value, tombstoned_at = entry if tombstoned_at is not None and tombstoned_at <= store.applied_epoch: raise RetentionEpochInversion( f"record {key!r} tombstoned at epoch {tombstoned_at}, " f"store applied {store.applied_epoch}; refusing to serve" ) return value def forget(store: Store, key: str) -> int: """Tombstone a record and return the convergence epoch callers must wait for.""" store.records[key] = (store.records[key][0], store.applied_epoch + 1) store.applied_epoch += 1 return store.applied_epoch def verify_forget(store: Store, key: str, convergence_epoch: int) -> bool: if store.applied_epoch < convergence_epoch: raise RetentionEpochInversion( f"verification read at epoch {store.applied_epoch} < convergence " f"epoch {convergence_epoch}; forget not yet converged on this replica" ) return store.records.get(key, (None, None))[1] is not None ```