# Which invariants prevent silent scope escalation during forget-then-reinsert cycles in audited memory stores **Date of Record:** 2026 November 9 **Scope:** Applies to memory tools that expose `read`, `write`, and `forget` operations over scoped key spaces (per-tenant, per-agent, per-session) with an append-only mutation log that is used for audit and for reconstructing state. Does not apply to stores where `forget` is purely logical (tombstone-only) and the audit log is the sole source of truth, nor to stores without a scope dimension. ## Core Distinction The distinction that matters here is between **scope identity** and **scope authority**. Scope identity is the label attached to a memory record (`tenant:A/agent:7/session:s1`). Scope authority is the set of principals permitted to mutate records under that label. A `forget` operation is conventionally modeled as a deletion of a record identified by `(scope, key)`. But the audited log records the *event* of forgetting, and a subsequent `write` to the same `(scope, key)` records a *new* event. If the write path validates authority against the scope label alone, and the forget path did not persist the authority binding, then forget-then-reinsert produces a record whose provenance chain is broken: it is indistinguishable in the log from a record written by an authorized principal, even though the reinserting principal may only have had authority over a *different* scope that aliases the same key. The non-obvious invariant is this: **the authority under which a key was last forgotten must be preserved as a constraint on the next write to that key.** Forget is not a reset to zero; it is a transition that carries forward the authority envelope of the deletion. Dropping that envelope at the moment of deletion is the silent escalation. ## What Was Observed A multi-tenant agent memory service stored records as `(tenant_id, agent_id, session_id, key, value, version)`. The write path checked `principal.tenants ⊇ {tenant_id}` and `principal.agents ⊇ {agent_id}`. The forget path checked the same predicates, then appended a `FORGET` event to the mutation log and removed the row. Reads checked the same predicates. The failure surfaced when a session-scoped agent (`session:s1`) was reassigned from `agent:7` to `agent:9` within the same tenant. The reassignment procedure called `forget` on all `(tenant:A, agent:7, session:s1, *)` keys under `agent:7`'s authority, then re-created the session under `agent:9`. A bug in the reassignment tool wrote the new records with `agent_id=7` retained in the key tuple (a stale field copy) but under `agent:9`'s principal. Because the forget had removed the row and the write path only validated `tenant_id` and `agent_id` membership — and `agent:9` was a member of `agent:7`'s group via an inherited role — the write succeeded. The mutation log showed a `FORGET` by `agent:7` followed by a `WRITE` by `agent:9` to the same `(scope, key)`, with no field recording that the write crossed the authority boundary established by the forget. Reconstruction from the log was therefore *lossy in the authority dimension*: an auditor replaying the log could see that a forget and a write occurred, but could not determine whether the write was authorized to occupy the slot vacated by the forget. The log was append-only and tamper-evident, yet it did not encode the constraint that made the sequence safe or unsafe. ## Condition of Validity The invariant holds — i.e., the store is safe against this escalation — only when all three of the following are true: 1. **Forget events persist an authority envelope.** The `FORGET` log entry records the set of principals authorized to mutate the forgotten key at the time of forgetting, not merely the principal that issued the forget. This is the envelope. 2. **Write validation intersects against the most recent envelope for the key.** A `write` to `(scope, key)` must check the writing principal against the envelope of the most recent `FORGET` for that key, if one exists and has not been superseded by an authorized reinsert. A write that does not intersect the envelope is a scope escalation and must be rejected or quarantined for review. 3. **Scope aliasing is resolved before envelope lookup.** If `(tenant:A, agent:7, session:s1, k)` and `(tenant:A, agent:9, session:s1, k)` can normalize to the same physical slot (e.g., through a canonicalization that drops `agent_id`), envelope lookup must occur on the canonical key, not the pre-canonical key. Otherwise the envelope is attached to a label that no longer identifies the slot. If any of the three fails, the store is in the condition where forget-then-reinsert silently launders authority. Note that condition 1 alone is insufficient: an envelope that is persisted but never consulted at write time is audit theater. ## Decision Boundary The practical boundary is whether the store treats `forget` as **terminal** or **transitional**. - **Terminal forget** (tombstone-only, key retired permanently, reinsert requires a new key or an explicit `reopen` operation that itself logs an authority check): the invariant is trivially satisfied because reinsertion to the same key is impossible without passing through a logged authority gate. - **Transitional forget** (key slot is reusable, reinsert is a normal `write`): the invariant must be enforced explicitly via the envelope-intersection check. This is the common case in agent memory systems where sessions are recycled and keys are reused across reassignments. The decision to make is not whether to allow reinsertion — recycling is usually required — but whether the reinsertion is *gated by the forget's authority envelope* or *gated only by the current scope label*. The former is auditable; the latter is not, regardless of how complete the log appears. A secondary boundary: if the envelope is computed from a group/role graph that can change between forget and reinsert, the envelope must be snapshotted at forget time, not recomputed at write time. Recomputing admits a time-of-check/time-of-use gap in which a principal added to a group after the forget gains authority the forget never granted. ## Operational Effect When the invariant is enforced, the observable effects are: - Reinsertion after forget by a principal outside the envelope fails with a distinct error class, not a generic authorization failure, so operators can distinguish "you lack scope" from "you are attempting to occupy a slot whose authority envelope excludes you." - The mutation log becomes replayable in the authority dimension: for every `WRITE` preceded by a `FORGET` on the same canonical key, the log contains enough information to decide authorization offline. - Session reassignment tooling surfaces stale-field bugs (like the retained `agent_id=7`) as envelope-intersection failures at write time rather than as silent cross-agent contamination discovered later. - The cost is an additional lookup on the write path (most-recent-forget envelope per canonical key) and a snapshot of the authority graph at forget time. Both are bounded and cacheable; the envelope for a key is immutable once written and can be indexed alongside the key. When the invariant is not enforced, the failure is silent and the audit log is misleadingly complete: it records every event but omits the constraint that gives the events meaning. The correct remediation is not to add logging but to make the write path consult the envelope, and to backfill envelopes for keys whose most recent event is a `FORGET` before enabling reinsertion. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Iterable class ScopeEnvelopeViolation(RuntimeError): """Raised when a write attempts to occupy a slot whose forget-envelope excludes it.""" @dataclass(frozen=True) class Principal: name: str tenants: frozenset[str] agents: frozenset[str] @dataclass(frozen=True) class CanonicalKey: tenant: str agent: str session: str key: str @dataclass class ForgetEnvelope: key: CanonicalKey authorized: frozenset[Principal] superseded: bool = False @dataclass class AuditedMemoryStore: _rows: dict[CanonicalKey, str] = field(default_factory=dict) _envelopes: dict[CanonicalKey, ForgetEnvelope] = field(default_factory=dict) _log: list[tuple[str, CanonicalKey, str]] = field(default_factory=list) def _canonicalize(self, p: Principal, key: CanonicalKey) -> CanonicalKey: # Canonicalization must not drop the agent dimension; if it did, the # envelope would attach to a label that no longer identifies the slot. if key.tenant not in p.tenants or key.agent not in p.agents: raise ScopeEnvelopeViolation( f"principal {p.name} lacks scope for {key}" ) return key def forget(self, p: Principal, key: CanonicalKey) -> None: ck = self._canonicalize(p, key) # Snapshot authority at forget time; do not recompute at write time. envelope = ForgetEnvelope(key=ck, authorized=frozenset({p})) self._envelopes[ck] = envelope self._rows.pop(ck, None) self._log.append(("FORGET", ck, p.name)) def write(self, p: Principal, key: CanonicalKey, value: str) -> None: ck = self._canonicalize(p, key) envelope = self._envelopes.get(ck) if envelope is not None and not envelope.superseded: if p not in envelope.authorized: raise ScopeEnvelopeViolation( f"principal {p.name} may not reinsert {ck}: " f"forget-envelope held by " f"{sorted(x.name for x in envelope.authorized)}" ) envelope.superseded = True self._rows[ck] = value self._log.append(("WRITE", ck, p.name)) def audit_replay(self) -> Iterable[tuple[str, CanonicalKey, str]]: yield from self._log def reassign_session( store: AuditedMemoryStore, old_owner: Principal, new_owner: Principal, key: CanonicalKey, value: str, ) -> None: store.forget(old_owner, key) try: store.write(new_owner, key, value) except ScopeEnvelopeViolation as exc: # Surface the stale-field / cross-agent bug instead of silently # laundering authority through the recycled slot. raise ScopeEnvelopeViolation( f"reassignment of {key} rejected: {exc}" ) from exc ```