# Which invariants prevent false forget-verification in read/write/forget memory tool schemas **Date of Record:** 2026 November 9 **Scope:** Applies to any agent runtime exposing `read`, `write`, and `forget` (or `delete`/`purge`) memory tools where `forget` returns a success signal and a subsequent `read` is used to confirm erasure. Does not apply to append-only stores where physical deletion is deferred to a compaction job with its own audit trail, nor to systems where `forget` is synchronous and returns a tombstone with a monotonic sequence number. ## Core Distinction The distinction that matters is between **logical erasure** (the key is no longer addressable by the current session, index, or namespace) and **verifiable erasure** (a read issued *after* the forget, against the *same* addressing path, returns a definitive absence rather than a stale or default value). Most `forget` implementations return success on logical erasure. Almost none guarantee that a post-forget `read` is a valid oracle, because the read path and the forget path frequently diverge: caches, read replicas, lazy expiration, and "not found → return empty string" defaults all conspire to make a successful forget indistinguishable from a failed one when observed through `read`. The invariant that prevents false verification is: **absence must be distinguishable from empty, and the read used to verify must traverse the same consistency domain as the forget.** A `read` that returns `""`, `null`, `[]`, or `"no results"` is not evidence of erasure unless the schema contractually guarantees that a forgotten key yields a *distinct* sentinel (e.g., `KeyNotFound`) rather than a zero value. ## What Was Observed An agent issued `forget(key="user_pref_timezone")`, received `{"status": "ok"}`, then issued `read(key="user_pref_timezone")` and received `{"value": ""}`. The agent logged the forget as verified. Three turns later, a `read` on a differently-cased key (`User_Pref_Timezone`) returned the original value `"America/Denver"`. The forget had been applied to a case-sensitive index; the read-verification had hit a case-insensitive fallback that returned the empty default for the forgotten key, masking the fact that the underlying record was still live under a second index entry. In a second instance, `forget` succeeded against the primary store, but the verification `read` was served by a read replica with ~400 ms replication lag and a "miss → empty" default. The read returned empty, the agent declared success, and a later read against the primary (after the replica caught up but before the tombstone propagated) returned the value. The forget was real; the *verification* was not, because it observed a replica that had not yet applied the delete and could not distinguish "deleted" from "not yet replicated." ## Condition of Validity The read-after-forget verification is valid **only if** all of the following hold: 1. The `read` is routed to the same consistency domain (primary, same namespace, same case-folding rules) as the `forget`. 2. The schema defines a distinct absence sentinel (`KeyNotFound` / `404` / explicit `found: false`) separate from any empty value the key could legitimately hold. 3. The `read` is issued after the forget's durability point (write-ahead log flush, quorum ack), not merely after the client received `{"status": "ok"}`. 4. No secondary index, alias, or case-variant of the key remains addressable. If any condition fails, the read is not an oracle—it is a probe that can return a false negative for erasure. The forget may still have succeeded; you simply cannot know from that read. ## Decision Boundary When the schema cannot satisfy all four conditions, the correct decision is to **treat the forget as unverified and escalate**, not to treat the empty read as confirmation. Concretely: - If `read` returns a zero value with no absence sentinel → **unverified**, do not log as erased. - If `read` returns `KeyNotFound` but the path differs from the forget path (different namespace, replica, or case) → **unverified**. - If `read` returns `KeyNotFound` on the identical path after the durability point → **verified**. - If a secondary index or alias is known to exist and was not explicitly forgotten → **partial erasure**, must issue forgets for each alias and re-verify each. The boundary is sharp: only an absence-sentinel read on the identical addressing path after the durability point counts. Everything else is a guess dressed as an audit. ## Operational Effect Agents that treat empty reads as forget-confirmation silently accumulate "verified" erasures that are not erased. This is worse than an unverified forget, because the audit log now contains a false attestation that downstream compliance or privacy logic will trust. The operational rule: **never verify a forget with a read whose schema does not distinguish absence from empty, and never verify across a consistency boundary you did not traverse on the forget itself.** When in doubt, re-issue the forget idempotently and verify with an explicit `exists`/`contains` probe that returns a boolean, not a value. ```python class ForgetVerificationError(RuntimeError): """Raised when a forget cannot be confirmed by a valid read-after-forget oracle.""" pass ABSENCE_SENTINEL = object() # distinct from "", None, [], etc. def verify_forget(memory_client, key, forget_path, *, durability_barrier=True): """ Verify a forget only if the read traverses the same consistency domain and the schema yields a distinct absence sentinel. Returns True only on a valid, positive verification. Raises ForgetVerificationError otherwise -- never returns a false positive. """ if not durability_barrier: raise ForgetVerificationError( f"refusing to verify forget of {key!r}: durability barrier not crossed; " "read may observe pre-delete state on a replica" ) result = memory_client.read(key, path=forget_path, sentinel=ABSENCE_SENTINEL) if result is ABSENCE_SENTINEL: return True # valid oracle: distinct absence on identical path if result in ("", None, [], {}): # Zero value is NOT evidence of erasure -- schema cannot distinguish # "forgotten" from "legitimately empty". raise ForgetVerificationError( f"forget of {key!r} unverified: read returned empty value, not an " "absence sentinel; schema cannot distinguish erased from empty" ) raise ForgetVerificationError( f"forget of {key!r} failed verification: value still present at {forget_path!r}" ) def forget_and_verify(memory_client, key, *, aliases=(), case_variants=()): paths = [key, *aliases, *case_variants] for p in paths: memory_client.forget(p) memory_client.flush_durability() # cross the barrier before verifying for p in paths: verify_forget(memory_client, key, forget_path=p, durability_barrier=True) return True ```