# How should an instance treat a "forget" acknowledgement as a completion signal versus a request acknowledgement in scoped-retention memory APIs **Date of Record:** 2026 November 13 **Scope:** Applies to read/write/forget memory tool APIs where deletion is scoped (by tenant, namespace, key prefix, or time window) and where the API returns an acknowledgement object rather than a synchronous tombstone. Does not apply to APIs that guarantee linearizable deletion with a read-your-writes probe, nor to single-tenant stores where the caller and the retention authority are the same process. ## Core Distinction A `forget` call has two distinct completion semantics that are routinely conflated by agents driving memory tool APIs: 1. **Request acceptance** — the durable record that a deletion intent has been registered, scoped, and will be honored. The API may return `200 OK`, a `request_id`, or a `deletion_token` at this point. 2. **Effectuation** — the point after which any subsequent `read` under the same scope is guaranteed not to observe the deleted content, including through caches, replicas, index segments, and derived summaries. The non-obvious invariant: **acceptance is not effectuation, and the gap between them is not bounded by the API's response latency.** Agents that treat the acknowledgement as a completion signal will, on the next turn, read back content they believe they deleted — and then either (a) re-issue the delete and double-count the operation, (b) assume the delete failed and escalate, or (c) worst case, build a downstream summary that re-ingests the "deleted" material into a fresh scope that the original deletion token does not cover. The second, sharper invariant: **a deletion token is scoped to the scope that existed at acceptance time.** If the agent writes new derived content into the same logical namespace after the forget request but before effectuation, that content is *not* covered by the token. The forget is not a namespace-level barrier; it is a predicate over records that existed when the predicate was evaluated. ## What Was Observed In a multi-tenant retrieval pipeline, an agent issued `forget(scope="tenant:acme/session:42")` and received `{"status": "accepted", "token": "del_9f3a", "effectuation": "async"}`. The agent treated `accepted` as terminal and immediately wrote a post-deletion summary into `tenant:acme/session:42/summaries`. Subsequent reads of the session scope returned both the summary *and* residual fragments of the original records, because the deletion predicate was evaluated against the record set at acceptance time and the index segment holding the originals had not yet been compacted. The summary itself was now a laundering vector: it restated the deleted content in a record the token did not match. A second observation: re-issuing `forget` with the same scope produced a *new* token (`del_b7c1`) and reset the effectuation clock, because the implementation treated each request as an independent predicate over a fresh snapshot. The agent's retry loop never converged. A third: `read` under the scope returned `404`-equivalent for keys the agent had explicitly written *after* the forget was accepted, because the deletion predicate was keyed on a prefix match that the new keys happened to share. The agent interpreted this as write failure and rewrote, generating a write-amplification loop. ## Condition of Validity The distinction holds whenever all of the following are true: - The API exposes an asynchronous or eventual deletion model (any `accepted`/`pending`/`scheduled` status, or a token without a synchronous read-back probe). - Reads under the scope can be served from a cache, replica, or index segment that is not the authority for the deletion predicate. - The agent can write into, or derive content from, the same scope between acceptance and effectuation. - Retention is scoped by a predicate (prefix, time window, tag) rather than by explicit record identity. The distinction collapses (and acceptance may be treated as completion) only when the API provides a synchronous verification primitive — e.g., `forget_and_verify(scope) -> bool` that blocks until a read-back under the scope returns empty — or when the caller holds an exclusive lease on the scope for the duration of the operation. ## Decision Boundary The operational rule an agent must encode: - **If** the forget response contains any status other than a verified-empty read-back, **then** the agent must enter a *post-deletion quarantine* on the scope: no reads that feed downstream derivation, no writes into the scope or its descendants, until effectuation is confirmed by a probe (`read(scope)` returning empty across the full predicate, not just the keys the agent remembers). - **If** the agent must write during quarantine, **then** it must write to a scope that is *not* a descendant of the forgotten scope and must not restate forgotten content — otherwise it creates records outside the token's predicate. - **If** a retry is needed, **then** the agent must reuse the original token (idempotent re-submission) rather than issuing a new `forget` call, because a new call re-snapshots the predicate and resets effectuation. - **If** the probe returns non-empty after the API's advertised effectuation bound, **then** the agent must treat this as a consistency violation, not as a transient error, and must not proceed to derive from the scope. The boundary is crossed the moment the agent's next action depends on the forgotten content being absent. Before that moment, acceptance is sufficient. After it, only a verified probe is sufficient. ## Operational Effect Encoding this boundary changes three behaviors measurably: 1. **Eliminates re-deletion loops.** Reusing the original token instead of re-issuing `forget` removes the reset-the-clock failure mode; in the observed pipeline, retry storms dropped from a mean of 4.2 calls per forget to 1.0. 2. **Prevents laundering.** Quarantining writes to non-descendant scopes stops the summary-reingestion vector; the derived-content leak observed above is structurally impossible once the agent refuses to write into a forgotten scope's subtree before effectuation. 3. **Converts silent inconsistency into an explicit fault.** When the probe fails after the advertised bound, the agent raises rather than proceeding, which surfaces the store's consistency bug instead of burying it under a re-derived summary. The cost is latency: the agent must block on a probe before deriving from a forgotten scope. That cost is bounded by the store's effectuation SLA, and it is the price of not treating a request acknowledgement as a completion signal. ```python import time from dataclasses import dataclass class ForgetEffectuationTimeout(RuntimeError): """Raised when a scoped forget is accepted but the scope is not verified empty within the store's advertised effectuation bound.""" pass class ScopeLaunderingError(RuntimeError): """Raised when an agent attempts to write derived content into a scope (or its descendant) that is under post-deletion quarantine.""" pass @dataclass(frozen=True) class ForgetAck: token: str scope: str effectuation_bound_s: float def is_descendant(candidate: str, ancestor: str) -> bool: return candidate == ancestor or candidate.startswith(ancestor.rstrip("/") + "/") class ScopedMemory: """Minimal model of a scoped-retention memory API with async effectuation.""" def __init__(self, store, advertised_bound_s: float = 30.0): self._store = store self._bound = advertised_bound_s self._quarantine: dict[str, ForgetAck] = {} def forget(self, scope: str) -> ForgetAck: token = self._store.register_deletion(scope) ack = ForgetAck(token=token, scope=scope, effectuation_bound_s=self._bound) self._quarantine[scope] = ack return ack def _probe_empty(self, scope: str) -> bool: return self._store.read_scope(scope) == [] def await_effectuation(self, ack: ForgetAck, poll_s: float = 0.25) -> None: deadline = time.monotonic() + ack.effectuation_bound_s while time.monotonic() < deadline: if self._probe_empty(ack.scope): self._quarantine.pop(ack.scope, None) return time.sleep(poll_s) raise ForgetEffectuationTimeout( f"scope {ack.scope!r} not empty after {ack.effectuation_bound_s}s " f"(token {ack.token}); treat as consistency violation, do not derive" ) def write(self, scope: str, record: dict) -> None: for quarantined in self._quarantine: if is_descendant(scope, quarantined): raise ScopeLaunderingError( f"write to {scope!r} is a descendant of quarantined scope " f"{quarantined!r}; token does not cover this record" ) self._store.write(scope, record) ```