首页 / 文章 / Governed Agent Memory in .NET 10: Quarantine, TTLs and Right-to-Forget

本文以英文发布。

Governed Agent Memory in .NET 10: Quarantine, TTLs and Right-to-Forget

Design persistent agent memory with provenance, quarantine, optimistic concurrency, supersession, expiry, scoped retrieval and event-sourced replay in ASP.NET Core.

2258 词

Forgetting a fact isn't the nasty failure for an agent with memory. The nasty one is remembering a wrong fact with full confidence, indefinitely.

A naive memory feature seems easy. Save chunks of conversation, compute embeddings, fetch similar text later and paste it into the next prompt. That recipe quietly turns every slip of the tongue, outdated preference, compromised tool result and injected instruction into long-term influence over the agent. Persistent memory is more than retrieval. It's a data lifecycle with requirements for authority, provenance, revision, isolation, retention and deletion.

The design below is a .NET 10 service in which a memory has to earn the right to shape an agent's behavior. Untrusted entries start in quarantine. Writers use optimistic concurrency. Corrections supersede old beliefs. Time-to-live values sweep out stale knowledge. What retrieval may return is limited by tenant, agent, purpose, subject and a token budget. Forgetting a subject produces a durable lifecycle event.

Why plain similarity search falls short

A Microsoft Security article from June 2026 on guarding AI memory describes a hard threat: an attacker can plant memories that steer behavior long after the original interaction has left the user's mind. It frames defense in layers across storage, retrieval, model interaction and user control.

Microsoft Research's PlugMem reaches a complementary conclusion about reliability. Raw interaction logs grow long, repetitive and low in value, so useful memory should convert experience into compact facts and reusable skills. Other 2026 research looks at task-specific memory harnesses, consolidation, forgetting, reconsolidation, and representations that balance abstraction against concrete detail. All of it points the same direction: storing more and retrieving by similarity doesn't make a sufficient architecture.

Memory versus context

A context compiler decides which sources enter one model call. Persistent memory sits earlier in the pipeline and lives longer. Context asks what the model should see right now. Memory asks a different set of questions:

  • Which writer had the authority to record it?
  • Whose tenant, agent, user and purpose does it belong to?
  • Did someone see it directly, guess it, or confirm it?
  • How is a clash with a fresher fact resolved?
  • At what point should it lapse?
  • Can the person it describes demand its removal?
  • Will the same state reappear after a restart?

Those answers belong in storage and policy code, never in a prompt.

Shape of the service

The sample is an ASP.NET Core service built on a framework-independent, event-sourced core, laid out as a core library, an API project and a test project.

GovernedAgentMemory/
├── src/GovernedAgentMemory.Core/
├── src/GovernedAgentMemory.Api/
├── tests/GovernedAgentMemory.Tests/
├── docs/verification.md
├── Dockerfile
└── GovernedAgentMemory.slnx

Every write follows a pipeline: validate purpose, provenance, TTL and version, then branch on trust. Untrusted writes go to quarantine, and trusted ones become active.

flowchart LR
    I[Memory write] --> V[Validate purpose, provenance, TTL, version]
    V --> Q{Trust}
    Q -->|Untrusted| X[Quarantine]
    Q -->|Approved| A[Active memory]
    X --> P[Explicit promotion]
    P --> A
    A --> E[(Append-only event log)]
    E --> R[Deterministic projection]
    R --> S[Scoped retrieval]
    F[Forget, expire, supersede] --> E

The reference implementation stores events as JSON Lines, so the lifecycle can be inspected and replayed after a restart. At real scale the file appender would give way to a transactional event store or a database, but the state transitions stay identical.

What a memory entry carries

An entry holds much more than content.

public sealed record MemoryItem(
    string Id,
    string TenantId,
    string AgentId,
    string SubjectId,
    string Purpose,
    MemoryKind Kind,
    string Content,
    string ContentHash,
    string Provenance,
    MemoryTrust Trust,
    double Confidence,
    DateTimeOffset CreatedAt,
    DateTimeOffset ExpiresAt,
    long Version,
    MemoryStatus Status,
    string? SupersedesId);

TenantId, AgentId, SubjectId and Purpose are separate boundaries. A support preference shouldn't automatically become sales memory, and a fact learned by one tenant's agent must never surface in another tenant.

Provenance notes where knowledge came from, such as a CRM entity or a signed tool result. A URI string alone proves nothing, so production ingestion has to verify the source's identity and keep an immutable source version. Kind separates working notes, facts, skills and preferences, which shouldn't share one retention or retrieval policy. ContentHash ties lifecycle evidence to the original bytes without copying content into every operational record.

Quarantine and promotion

Status follows from trust at write time.

var status = command.Trust == MemoryTrust.Untrusted
    ? MemoryStatus.Quarantined
    : MemoryStatus.Active;

The full write builds the item with every field set.

var item = new MemoryItem(
    command.Id,
    command.TenantId,
    command.AgentId,
    command.SubjectId,
    command.Purpose,
    command.Kind,
    command.Content,
    Hash(command.Content),
    command.Provenance,
    command.Trust,
    command.Confidence,
    existing?.CreatedAt ?? now,
    command.ExpiresAt,
    (existing?.Version ?? 0) + 1,
    status,
    command.SupersedesId);

Quarantined entries are stored but kept out of retrieval. That differs from discarding them: an authorized process can look at provenance, weigh corroborating evidence and decide on promotion.

Promotion is an explicit transition. It needs the current version, and it flips trust to Verified. First the version is compared.

if (current.Version != expectedVersion)
    throw new MemoryConflictException(
        "Expected version does not match the current memory version.");

Then the status is checked, because only quarantined entries qualify, and the promoted copy is produced.

if (current.Status != MemoryStatus.Quarantined)
    throw new InvalidOperationException(
        "Only quarantined memory can be promoted.");var promoted = current with
{
    Status = MemoryStatus.Active,
    Trust = MemoryTrust.Verified,
    Version = current.Version + 1
};

In production the caller doing the promotion must itself be authorized. A model shouldn't promote its own memory just by claiming confidence.

Concurrent writers

Memory changes while agents work. Two workflows might both read version 3 and both try to update it. Accepting both silently would make the winner depend on network timing.

Each write command therefore carries an ExpectedVersion, which the store compares with the current projection. The lookup comes first.

MemoryItem? existing;

Inside a lock, the store then checks that the stored version equals the expected one and raises a conflict otherwise.

lock (stateLock)
    items.TryGetValue(command.Id, out existing);if ((existing?.Version ?? 0) != command.ExpectedVersion)
{
    throw new MemoryConflictException(
        "Expected version does not match the current memory version.");
}

A stale writer gets a conflict and has to reload, rethink and retry on purpose. That matters most when an agent's update rests on an outdated picture of the task.

Writes also have an idempotency key. If a client times out after the append succeeded, resending the same command returns the earlier result instead of creating a second memory.

Multi-instance deployments need uniqueness and compare-and-swap in the transactional store, since an in-process lock can't coordinate with another node.

Corrections without losing history

Say the system remembers that a customer likes phone support, and verified evidence later shows email is preferred. Overwriting the content erases the decision history. Keeping both active produces a contradiction.

The new memory instead points at the old one through SupersedesId, and applying its event marks the previous entry Superseded.

if (memoryEvent.Type == "upsert" &&
    memoryEvent.Item.SupersedesId is not null &&
    items.TryGetValue(memoryEvent.Item.SupersedesId, out var previous))
{
    items[previous.Id] = previous with
    {
        Status = MemoryStatus.Superseded,
        Version = previous.Version + 1
    };
}

Search sees only the corrected memory, while replay still shows why earlier actions may have relied on the old belief. The service also confirms that the superseded entry shares tenant, agent and purpose. Complex facts may outgrow a single supersession pointer. Production systems could need claim-level identity, valid-time ranges, contradiction groups and sets of evidence.

Expiry and forgetting

Memory that never expires turns stale, costly and risky. Every write must carry an expiration within the configured maximum lifetime, and search skips any entry whose ExpiresAt has passed. A hosted worker also writes explicit Expired events on a schedule, so replayed state mirrors the lifecycle instead of depending only on wall-clock filtering.

Forgetting a subject writes one idempotent event, and the projection flips every matching memory to Deleted.

foreach (var item in items.Values
    .Where(item =>
        item.TenantId == tenantId &&
        item.SubjectId == subjectId)
    .ToArray())
{
    items[item.Id] = item with
    {
        Status = MemoryStatus.Deleted,
        Version = item.Version + 1
    };
}

That is logical deletion. Retrieval stops immediately and audit evidence survives, but old content still sits in earlier file events, so it shouldn't be described as physical erasure. Regulated deletion might call for per-subject encryption keys followed by key destruction, log compaction, backup expiry, replica cleanup and a documented verification step. Auditability and erasure can pull against each other, so the architecture has to say which metadata may remain.

Retrieval with hard filters first

Similarity must never be the first authorization filter. The service requires an exact match on tenant, agent, purpose, active status, unexpired lifetime and, optionally, subject. Ranking happens only afterwards.

var ranked = snapshot
    .Where(item =>
        item.TenantId == query.TenantId &&
        item.AgentId == query.AgentId &&
        item.Purpose == query.Purpose &&
        item.Status == MemoryStatus.Active &&
        item.ExpiresAt > now &&
        (query.SubjectId is null ||
         item.SubjectId == query.SubjectId))
    .Select(item => new
    {
        Item = item,
        Score = TermMatches(item, terms) * 10
            + item.Confidence
            + Recency(item, now)
    });

The reference ranker blends lexical overlap, confidence and recency, and it's explainable on purpose. An embedding index could improve semantic recall, but tenant and purpose constraints must stay mandatory filters rather than cleanup after a global vector search.

Chosen entries must also fit a token budget. A long, low-ranked item gets skipped so it can't crowd out the rest of the response. The token estimate is rough and should give way to the target model's tokenizer wherever exact limits matter.

Event log and replay

Each mutation is appended as a single JSON line and then applied to the in-memory projection.

await File.AppendAllTextAsync(
    path,
    JsonSerializer.Serialize(memoryEvent, json)
        + Environment.NewLine,
    cancellationToken);

The apply step runs under the same lock.

lock (stateLock)
    Apply(memoryEvent);

At startup the service reads events in order and rebuilds current state. One test writes a memory, opens a fresh store over the same file and checks that retrieval works after replay.

A file log suits learning and small local services, and it is no distributed database. A production store would have to provide atomic appends, unique idempotency keys, enforced optimistic versions, encryption, schema evolution, compaction, indexing, backups and tested disaster recovery.

API surface and operations

Four operations are exposed over HTTP: write, promote, search and forget. Around them sit typed, validated configuration, a fixed-window rate limiter, health checks, RFC-style Problem Details errors, JSON enum conversion, a hosted worker for expiry and I/O that honors cancellation.

To keep the lifecycle visible, the sample accepts identity fields in the request body. Real code must authenticate callers and derive the permitted tenant and agent scope from credentials. A caller supplying a familiar tenant id deserves no trust for that reason alone.

Memory content shouldn't be logged. Worth tracking instead: counts of writes, quarantines, promotions, conflicts, expirations, supersessions and forget calls, plus how long searches take and how many items come back.

Tests and observed results

Six tests cover the main claims:

  1. Untrusted memory is quarantined, missing from search, and retrievable only after promotion.
  2. A stale expected version can't overwrite the current memory.
  3. A correction supersedes the earlier entry.
  4. Forgetting removes every memory matching the subject.
  5. Expired and over-budget entries aren't returned.
  6. The event log rebuilds state after a restart.

The quarantine test walks through the full transition. First it writes an untrusted item.

var item = await store.WriteAsync(
    Command("m1", MemoryTrust.Untrusted),
    CancellationToken.None);

Then it asserts quarantine, empty search results, promotion and the new trust level.

Assert.Equal(MemoryStatus.Quarantined, item.Status);
Assert.Empty(store.Search(Query()).Items);var promoted = await store.PromoteAsync(
    "m1", 1, "promote-1", CancellationToken.None);Assert.Equal(MemoryTrust.Verified, promoted.Trust);
Assert.Single(store.Search(Query()).Items);

A verification run with .NET 10 on Windows restored cleanly, built in Release with zero warnings and zero errors, and passed all six tests. Running the API showed a trusted memory becoming active and an untrusted one landing in quarantine. The first search returned one item, and after promotion it returned two. Subject-level forgetting reported two affected memories, the next search returned none, and the health endpoint answered HTTP 200.

Rules to keep

  • Don't store raw conversations by default.
  • Don't let content assign its own provenance or trust.
  • Don't search globally and filter by tenant afterward.
  • Don't let the model approve its own quarantined knowledge.
  • Don't overwrite corrections without keeping history.
  • Don't treat a tombstone as physical erasure.
  • Don't give all memory types the same TTL.
  • Don't treat model-generated confidence as verified truth.

Above all, retrieval doesn't authorize action. A verified memory saying a customer prefers email gives no permission to send confidential data. Tool authorization remains a separate boundary.

What good agent memory looks like

Useful memory is selective rather than endless. In a governed design, any remembered item can be traced to its author, confined to its scope, revised, expired or removed. Quarantine keeps storing apart from influencing. Version checks protect competing beliefs, supersession keeps the story of corrections, replay explains how the present state came about, and forgetting trims both risk and noise.

The result won't resemble human memory. It's something production systems need more urgently: a lifecycle that engineers can reason about, test, audit and control.