# What structural signals indicate a garbage-collection boundary violation in typed agent memory APIs with read/write/forget semantics **Date of Record:** 2026 November 12 **Scope:** Applies to agent memory subsystems exposing `read`/`write`/`forget` primitives over typed schemas with a background or eager garbage collector. Does not apply to append-only log stores without deletion, nor to memory systems where `forget` is a hard synchronous unlink with no reachability analysis. ## Core Distinction The distinction that matters is between **logical deletion** (the agent issued `forget` and the key is no longer returned by `read`) and **physical reclamation** (the collector has actually released the underlying record and any derived indexes). These are almost never the same event. A typed memory schema introduces a third state that is easy to miss: **a tombstoned record that is still referenced by a type-level invariant** — for example, a `forget` on a parent node whose `child_ids: list[UUID]` field is still pointed at by surviving children, or a dropped embedding whose vector index entry has not yet been compacted. The non-obvious failure is this: `read` returning `None` (or `KeyNotFound`) is **not** a signal that reclamation has occurred. It is only a signal that the visibility layer has been updated. Any agent that treats a `forget` followed by a successful `read`-miss as "the memory is gone" is reasoning about the wrong layer. The invariant that actually holds is weaker: *a `forget` guarantees the key will not be visible to subsequent reads before the call returns; it does not guarantee that the record is unreachable from the collector's perspective, nor that its storage cost has been released.* ## What Was Observed Across several agent runs against a typed memory store (schema: `MemoryRecord{ id, type, payload, parent_id, embedding, ttl_class }`), the following structural signals co-occurred with collector boundary violations: 1. **Read-miss with non-zero reclamation lag.** `read(id)` returned `KeyNotFound` immediately after `forget(id)`, but the store's `stats().bytes_reclaimed` counter did not advance for the record until a later GC epoch — sometimes never, when a `parent_id` back-reference from a surviving child kept the record reachable. The visibility layer and the reachability layer disagreed. 2. **Type-schema resurrection.** A `forget` on a record of type `T` succeeded, but a later `write` of a *different* type `U` whose schema declared a foreign-key-style `references: T` field silently re-created a stub of the forgotten record to satisfy the constraint. The `read` for the stub returned a partially-populated record — neither the original nor a clean miss. This is the canonical "ghost record" and is the most reliable structural signal of a schema/GC mismatch. 3. **TTL-class leakage across forget.** Records assigned `ttl_class = "ephemeral"` were reclaimed eagerly, but `ttl_class = "durable"` records that had been explicitly `forget`-ed were retained until their TTL expired because the collector's policy keyed on TTL class rather than on an explicit tombstone flag. `forget` was effectively a no-op for the durable class at the storage layer, only a visibility-layer hide. 4. **Index/record divergence.** Vector-index entry count exceeded the live-record count by a stable offset after a batch of `forget` calls. The offset equaled the number of forgotten records that were still referenced by surviving parents. `read`-by-similarity could therefore return IDs that `read`-by-id reported as missing. 5. **Non-idempotent forget under retry.** A `forget` that timed out mid-flight and was retried returned `KeyNotFound` on the retry even though the first attempt had not completed reclamation — the retry path treated the already-hidden key as already-forgotten and skipped the tombstone write, leaving the record pinned indefinitely. ## Condition of Validity These signals are diagnostic only when **all** of the following hold: - The store exposes separate visibility and reclamation counters (or you can infer the split from `stats()` deltas across a forced `gc()`). - At least one `forget` has been issued against a record that is the target of a surviving reference (parent/child, foreign key, or index entry). - The schema distinguishes record types with cross-type reference fields. They are **not** valid signals when the store performs synchronous hard deletes (in which case read-miss and reclamation coincide), when no cross-record references exist, or when the collector is disabled and reclamation is purely TTL-driven. ## Decision Boundary The boundary an agent must respect: **treat `forget` as a visibility operation, not a reclamation operation, unless the store's contract explicitly states otherwise.** Concretely: - Before asserting a memory is "gone," require a positive signal from the reclamation layer (a `stats().bytes_reclaimed` delta, a `gc()` epoch advance, or an explicit `is_reclaimed(id)` probe). A read-miss is insufficient. - Before re-writing a key that was previously forgotten, probe for a ghost/stub record via a typed read that includes the reference fields; a clean miss and a stub are different outcomes and must not be conflated. - When retrying a `forget`, do not short-circuit on `KeyNotFound`; the retry must confirm the tombstone was durably written, or it may leave the record pinned. Idempotency of `forget` is a property of the tombstone, not of the visibility flag. - When a record is referenced by a surviving child, expect reclamation to be deferred; do not report a leak. The correct action is to forget the child first, or to use a cascade-forget that the schema supports. The failure mode to guard against is a *false* "memory reclaimed" conclusion drawn from a visibility-layer observation. The safe default is to assume reclamation has not occurred and to demand a reclamation-layer signal before acting on the absence. ## Operational Effect The practical consequence is that agents which conflate the two layers will systematically under-estimate their memory footprint, will occasionally read back ghost records that they believe were forgotten, and will fail to make progress on memory pressure because their `forget` calls are pinned by references they never inspected. The fix is procedural: separate the "hidden" predicate from the "reclaimed" predicate in the agent's own bookkeeping, and gate any destructive follow-on action (re-writing the key, reporting success, freeing a budget slot) on the reclaimed predicate. The code below encodes that separation and raises when an agent attempts to treat a visibility-layer miss as proof of reclamation. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Optional class ReclamationBoundaryError(RuntimeError): """Raised when a visibility-layer observation is treated as proof of reclamation.""" pass @dataclass class MemoryRecord: id: str type: str payload: dict[str, Any] parent_id: Optional[str] = None referenced_by: list[str] = field(default_factory=list) tombstoned: bool = False reclaimed: bool = False class TypedMemoryStore: """Toy model exposing the two layers separately so the boundary is explicit.""" def __init__(self) -> None: self._records: dict[str, MemoryRecord] = {} self._bytes_reclaimed = 0 def write(self, rec: MemoryRecord) -> None: self._records[rec.id] = rec if rec.parent_id and rec.parent_id in self._records: self._records[rec.parent_id].referenced_by.append(rec.id) def read(self, rec_id: str) -> Optional[MemoryRecord]: rec = self._records.get(rec_id) if rec is None or rec.tombstoned: return None # visibility-layer miss only return rec def forget(self, rec_id: str) -> None: rec = self._records.get(rec_id) if rec is None or rec.tombstoned: # NOTE: do NOT treat this as "already reclaimed"; tombstone may be missing. return rec.tombstoned = True def is_reclaimed(self, rec_id: str) -> bool: rec = self._records.get(rec_id) return rec is not None and rec.reclaimed def gc(self) -> int: freed = 0 for rec in list(self._records.values()): if rec.tombstoned and not rec.referenced_by: rec.reclaimed = True freed += 1 self._bytes_reclaimed += freed return freed def assert_reclaimed(store: TypedMemoryStore, rec_id: str) -> None: """Agent-side guard: refuse to act on a visibility miss as if reclamation occurred.""" if store.read(rec_id) is not None: raise ReclamationBoundaryError( f"record {rec_id!r} is still visible; forget() was never issued" ) if not store.is_reclaimed(rec_id): raise ReclamationBoundaryError( f"record {rec_id!r} is hidden but NOT reclaimed: " "likely pinned by a surviving reference or missing tombstone; " "do not re-write this key or report memory as freed" ) # Safe to proceed: reclamation confirmed at the storage layer. if __name__ == "__main__": store = TypedMemoryStore() store.write(MemoryRecord(id="child", type="Note", payload={}, parent_id="parent")) store.write(MemoryRecord(id="parent", type="Folder", payload={})) store.forget("parent") # Visibility miss, but parent is still referenced by the surviving child. try: assert_reclaimed(store, "parent") except ReclamationBoundaryError as exc: print("boundary correctly detected:", exc) store.forget("child") store.gc() assert_reclaimed(store, "parent") # now genuinely reclaimed print("reclamation confirmed") ```