# Where must an instance refuse to serve a memory read after a TTL boundary: the stale-window invariant in scoped read/write/forget memory APIs **Date of Record:** 2026 November 11 **Scope:** Applies to memory tool APIs that expose `read`, `write`, and `forget` operations over scoped keys with TTL-based expiration and auditable deletion. Does not apply to append-only event logs where reads are replayed from a durable source of truth, nor to caches where stale reads are explicitly permitted by the caller contract. ## Core Distinction The distinction that matters is between **logical expiration** (the TTL has elapsed by wall-clock or monotonic time) and **physical deletion** (the record has actually been removed from the backing store and its deletion is recorded in the audit trail). These are not the same event, and the gap between them is where most memory-tool bugs live. A memory API that returns "not found" for an expired key is making a *logical* claim. A memory API that has physically purged the record and written an audit entry is making a *physical* claim. The non-obvious invariant is this: **a `read` that observes a key past its TTL must not return the value, even if the physical record still exists, and a `forget` that reports success must not report success until the audit entry is durable.** The failure mode is a read served from a record that is logically dead but physically present, racing against a sweeper that has not yet run — a *stale window*. This matters because agents compose these primitives. An agent that writes a credential, forgets it, and then reads it back to confirm deletion will observe a resurrection if the read path trusts physical presence over TTL metadata. The audit trail then records a deletion that the read path contradicts. ## What Was Observed In a scoped memory store with per-key `expires_at` metadata, a background sweeper, and a separate audit sink, the following sequence reproduced intermittently under load: 1. Agent writes key `k` under scope `s` with TTL 60s. Record stored with `expires_at = T0 + 60`. 2. At `T0 + 61`, agent calls `forget(k, scope=s)`. The `forget` path finds the record, marks it `deleted=true`, and enqueues an audit event to a durable sink asynchronously. 3. `forget` returns success to the agent. 4. At `T0 + 61.2`, agent calls `read(k, scope=s)` to verify. The read path checks `deleted` flag — but the flag write and the read raced across replicas, so the read hit a replica where `deleted` was still false. The read also did not check `expires_at` against the current clock on that code path, because the expired-record path was assumed to be handled by the sweeper. 5. The read returned the value. The agent logged a successful resurrection of a forgotten credential. The audit sink, meanwhile, had not yet flushed the deletion event. So for a window of roughly 200–900ms, the system simultaneously (a) served a value the caller believed forgotten, and (b) had no durable record that a deletion had been requested. Two independent invariants were violated: the read path trusted physical presence over logical expiration, and the forget path reported success before audit durability. ## Condition of Validity The stale-window invariant holds — that is, the system is correct — only when all three of the following are true: - **Read path checks TTL on every access.** The read must evaluate `now >= expires_at` against the same clock domain used at write time, and must treat an expired-but-present record as absent. It must not delegate this to the sweeper. - **Forget path is synchronous with respect to audit durability.** `forget` must not return success until the audit entry is acknowledged as durable by the sink, or must return a distinguishable "pending" status that callers are contractually required to handle. - **Scope isolation is enforced at the read path, not only at the index.** A read for `(k, scope=s2)` must not match a record written under `(k, scope=s1)` even if the physical key collides, and must not leak existence information across scopes via timing or error codes. When any of these is absent, the API is only *eventually* correct, and the eventual-consistency window is exactly the stale window. Callers that treat `forget` as a barrier — which is the natural reading of an auditable deletion guarantee — will be wrong. ## Decision Boundary The decision boundary is whether the caller needs a **barrier** or a **hint**. If `forget` is a hint (best-effort cleanup, no security or compliance implication), asynchronous audit and sweeper-based expiration are acceptable, provided `read` still checks TTL. If `forget` is a barrier (the caller will act on the assumption that the value cannot be read again, and an auditor will check that assumption), then `forget` must be linearizable with respect to subsequent reads in the same scope, and the audit entry must be durable before success is reported. The practical rule: **never let a read path and a forget path disagree about whether a key is live.** Concretely, the read path must consult the same `deleted`/`expires_at` state that `forget` mutates, and that state must be read with a consistency level at least as strong as the one `forget` wrote with. If the store cannot offer that, the read path must treat any record whose `deleted` flag is not *positively confirmed false on a consistent read* as absent — fail closed. There is a second boundary: the TTL clock domain. If `expires_at` is written using wall-clock time and read using monotonic time (or across hosts with clock skew), the stale window widens by the skew. The invariant requires a single clock domain, or a safety margin subtracted from the TTL at write time that exceeds the maximum expected skew. ## Operational Effect In practice, enforcing the invariant changes three things: - Reads become slightly more expensive (a TTL comparison and a consistent-state check per access), but the resurrection class of bugs disappears. - `forget` latency increases to include audit-sink round-trip, which must be budgeted. If the sink is slow, `forget` must fail or return pending rather than silently degrade to a hint. - The sweeper becomes an optimization, not a correctness mechanism. It reclaims space; it does not define liveness. This is the key reframing: any design where correctness depends on the sweeper having run is already broken. The operational lesson for agents: when a memory tool offers `forget`, do not assume the value is unreadable until you have confirmed the tool's contract distinguishes barrier from hint. If it does not, read-after-forget is not a valid verification step, and you must verify via the audit interface instead — or treat the tool as a cache and re-key secrets so that resurrection is harmless. ```python import time from dataclasses import dataclass, field from typing import Optional class StaleWindowError(RuntimeError): """Raised when a memory read would serve a logically dead record, or when forget reports success before audit durability.""" pass class AuditNotDurableError(StaleWindowError): """Raised when forget cannot confirm the audit entry is durable.""" pass @dataclass class Record: value: str scope: str expires_at: float deleted: bool = False audit_durable: bool = False class ScopedMemory: def __init__(self, clock=time.monotonic, max_skew: float = 0.0): self._store: dict[tuple[str, str], Record] = {} self._clock = clock self._max_skew = max_skew def write(self, key: str, scope: str, value: str, ttl: float) -> None: # Subtract max_skew so the record expires conservatively even if a # reader's clock is ahead of the writer's. expires_at = self._clock() + ttl - self._max_skew self._store[(scope, key)] = Record(value, scope, expires_at) def read(self, key: str, scope: str) -> Optional[str]: rec = self._store.get((scope, key)) if rec is None: return None if rec.deleted: return None # TTL must be checked on every read; do not rely on a sweeper. if self._clock() >= rec.expires_at: return None return rec.value def forget(self, key: str, scope: str, audit_sink) -> None: rec = self._store.get((scope, key)) if rec is None: return rec.deleted = True # Mark deleted BEFORE attempting audit, so a concurrent read fails # closed even if the audit call raises. try: audit_sink.append({"op": "forget", "scope": scope, "key": key}) except Exception as exc: raise AuditNotDurableError( f"forget({scope!r},{key!r}) marked deleted but audit not durable: {exc}" ) from exc rec.audit_durable = True def read_after_forget_verify(self, key: str, scope: str) -> None: """Verification helper: a read after forget must never resurrect.""" rec = self._store.get((scope, key)) if rec is None: return if not rec.deleted: raise StaleWindowError( f"record ({scope!r},{key!r}) still live after forget; " "forget did not reach this replica" ) if not rec.audit_durable: raise AuditNotDurableError( f"record ({scope!r},{key!r}) deleted but audit entry missing" ) # Positive confirmation: the value must be unreadable. if self.read(key, scope) is not None: raise StaleWindowError( f"resurrection: read({scope!r},{key!r}) returned a forgotten value" ) ```