The hardest part of adding memory to an AI app is not the first turn. It is turn 10,000 with two data centers, three storage backends, and a compliance requirement.

The core abstractions — IConversationStorage, VectorStoreCollection, middleware, augmentation — handle the basic flow. But production deployments face questions that the basic flow does not answer:

  • How do you isolate memory between tenants without leaking facts?
  • What happens when two background jobs update the same fact concurrently?
  • How do you summarize a conversation that has grown beyond the model’s context window?
  • Can you write to an in-memory store during development and a production database at runtime without changing application code?
  • How do you let users see, edit, and delete what the AI remembers about them?

These are not edge cases. They are the operational reality of a memory layer that runs long enough to matter. Each one has a concrete solution, and you can adopt them independently as your deployment grows.

Scope isolation for multi-tenant systems

Every MemoryFactRecord carries an optional Scope string. When scope is set, it acts as a partition key that restricts recall, management, and conversation operations to a single workspace or tenant.

memori.Attribution("user_123");
memori.SetScope("workspace-a");

// Only facts in "workspace-a" are returned
var results = await memori.RecallAsync("project deadlines");

memori.ClearScope();

// All scopes are searched
var allResults = await memori.RecallAsync("project deadlines");

The design is deliberately simple. Scope is a string — it can be a workspace ID, a tenant slug, an organization GUID, or any other partition identifier. The vector store applies it as a filter during search. Conversation storage also accepts the scope parameter on entity and session operations.

Scope isolation matters most in two patterns:

B2B SaaS: Each customer’s facts should never leak into another customer’s recall. Set scope to the customer’s tenant ID at the start of each request.

Multi-project workspaces: A user participates in multiple projects. Each project has its own memory context. Set scope when the user switches projects, clear it when they want a cross-project view.

The rule is straightforward: scope when you must partition, clear when you must aggregate. The library does not enforce either mode — it provides the mechanism and leaves the policy to the application.

One lesson from building this: scope was originally an afterthought, added after a production incident where a support agent’s recall returned facts from a different customer’s conversation. The fix was adding scope filtering to the search path. The lesson was that memory isolation should be a first-class feature, not a storage-level workaround.

Versioning turns concurrent writes from a bug into a contract

Background augmentation, retry logic, and manual memory edits create concurrent write scenarios. Two processes can read the same fact, modify it, and write it back — the second write silently overwriting the first.

Memori’s versioning system addresses this with optimistic concurrency control:

var versioning = new VersioningService(ConflictResolutionStrategy.LastWriteWins);

var existing = await factCollection.GetAsync("fact-id");
var resolution = versioning.ResolveConflict(incoming, existing, expectedVersion: 1);
await factCollection.UpsertAsync(resolution.ResolvedRecord);

Each MemoryFactRecord carries:

  • Version — monotonic counter incremented on each update
  • PreviousVersionId — link to the prior version for audit trails
  • IsDeleted — soft-delete flag

Conflict resolution follows one of three strategies:

  • LastWriteWins (default): the most recent write succeeds unconditionally. High throughput, accepts occasional data loss.
  • Merge: when content differs, conflicting text is combined with a semicolon delimiter. Useful for facts that accumulate attributes.
  • Manual: the conflict is flagged for human or external resolution. The new version is stored with a conflict marker.

The right default depends on your domain. For preference memory, last-write-wins is usually acceptable — the most recently stated preference is the correct one. For compliance-related facts, manual resolution may be required. Merge sits in between, appropriate for facts that are additive rather than replacement-based.

The versioning service is opt-in. Wire it through AugmentationService when versioned writes are required. By default, writes go directly to the VectorStoreCollection without conflict checks.

Thread summarization compresses without losing signal

Long conversations exceed context windows. The options are to truncate (losing information), to use a model with a larger window (expensive), or to summarize.

IThreadSummarizer defines the contract:

public interface IThreadSummarizer
{
    Task<string> SummarizeAsync(
        IReadOnlyList<ConversationMessage> messages,
        CancellationToken cancellationToken = default);

    Task<string> SummarizeAsync(
        IReadOnlyList<ConversationMessage> messages,
        string previousSummary,
        CancellationToken cancellationToken = default);
}

The default implementation — ChatClientThreadSummarizer — sends conversation messages to any IChatClient with a summarization prompt. Configuration controls:

  • MaxMessagesPerSummary (default: 50) — caps messages per request to stay within the model’s effective context
  • IncludeTimestamps — prepends timestamps for temporal context
  • SummaryMemoryType — the MemoryType value for stored summaries (default: "summary")
var summarizer = new ChatClientThreadSummarizer(myChatClient)
{
    MaxMessagesPerSummary = 30,
    IncludeTimestamps = true
};

Summaries are stored as MemoryFactRecord entries and can participate in recall when IncludeSummariesInPrompt is enabled on MemoriOptions.

The rolling pattern is important here. Instead of summarizing the entire conversation from scratch each time, the summarizer takes the previous summary and new messages as input, producing an updated summary. This keeps summarization costs linear in conversation growth rather than quadratic.

Memory management is a product requirement, not a debugging tool

Users expect to see what an AI remembers about them. Privacy regulations require it. Both demand more than a raw VectorStoreCollection.GetAsync.

IMemoryManagementService provides the user-facing API:

public interface IMemoryManagementService
{
    Task<PaginatedResult<MemoryFactRecord>> ListMemoriesAsync(
        string entityId, PaginationOptions pagination);
    Task<SearchResult<MemoryFactRecord>> SearchMemoriesAsync(
        string entityId, string query, SearchOptions options);
    Task<MemoryFactRecord?> GetMemoryAsync(string memoryId);
    Task UpdateMemoryAsync(string memoryId, string newContent);
    Task SoftDeleteMemoryAsync(string memoryId);
    Task HardDeleteMemoryAsync(string memoryId);
    Task RestoreMemoryAsync(string memoryId);
    Task<int> GetMemoryCountAsync(string entityId);
}

This API is designed to back settings pages, privacy dashboards, admin panels, or any interface where users need visibility into what the AI remembers. The MemoriEngine facade exposes convenience methods that auto-scope to the current attribution entity:

var memories = await memori.ListMemoriesAsync();
await memori.SoftDeleteMemoryAsync("fact-id");
await memori.RestoreMemoryAsync("fact-id");

Soft-delete deserves emphasis. Users delete things by accident. A soft-delete with a restore window is the difference between a recoverable mistake and a support ticket. The IsDeleted flag on MemoryFactRecord makes this trivial to implement, and the management API exposes it consistently.

Composite collections for multi-backend setups

Some teams want the safety of a production database with the speed of an in-memory cache. Some are migrating from one backend to another. Some want to write to two backends for redundancy during a cutover.

CompositeMemoryCollection wraps multiple VectorStoreCollection<string, MemoryFactRecord> backends and presents them as one:

var composite = new CompositeMemoryCollection(
    new[] { inMemoryCollection, productionCollection },
    new CompositeMemoryCollectionOptions
    {
        WriteStrategy = CompositeWriteStrategy.All,
        MaxConcurrency = 4
    });
  • Reads: queries fan out to all backends in parallel. Results are deduplicated by record key and merged via IDistributedRanker for a unified ranked list.
  • Writes: configurable via CompositeWriteStrategy — write to all backends (All) or only the primary (PrimaryOnly).
  • Graceful degradation: per-backend failures are isolated. If the production database is slow or unreachable, remaining backends still return results.

The composite pattern is not for every deployment, but it solves real problems: zero-downtime backend migration, read-through caching, and multi-region setups where each region has a local copy.

A realistic adoption path

The most common mistake teams make when adopting a memory library is trying to configure everything before writing the first message.

Memori is designed for incremental adoption. The path looks like this:

Step 1: Start with in-memory and defaults

services.AddMemori(options =>
{
    options.SessionTimeout = TimeSpan.FromMinutes(30);
});

This registers InMemoryConversationStorage, InMemoryVectorStore, NullAugmentationClient, and DeterministicEmbeddingGenerator. Everything works without a database connection, an API key, or a vector store configuration.

Step 2: Add the middleware

IChatClient client = new ChatClientBuilder(innerClient)
    .UseMemori(memori)
    .Build(serviceProvider);

Recall, injection, and capture are now automatic. No application code changes beyond setup.

Step 3: Add real embeddings

Replace DeterministicEmbeddingGenerator with a production embedding provider:

services.AddMemori(
    embeddingGeneratorFactory: sp => myProductionEmbeddingGenerator);

Recall quality improves immediately. The in-memory storage still works — no backend changes needed.

Step 4: Swap storage backends

Replace InMemoryConversationStorage with a production implementation:

services.AddSingleton<IConversationStorage, PostgresConversationStorage>();
services.AddSingleton<VectorStore>(sp => new MyProductionVectorStore(endpoint, credential));
services.AddMemori();

Application code does not change. The storage contracts are the same.

Step 5: Add augmentation

Replace NullAugmentationClient with PromptAugmentationClient or a custom implementation:

services.AddMemori(
    augmentationClientFactory: sp => new PromptAugmentationClient(myChatClient));

Facts, triples, and summaries are now extracted in the background after each capture.

Step 6: Enable enterprise features

Add scope isolation, versioning, thread summarization, and memory management as the deployment requires them. Each is optional, backward-compatible, and independent of the others.

This progression is not hypothetical. It is the path every production deployment of Memori has followed so far. No team has needed all six steps on day one.

What comes next

Phase 3 of the library targets features that were explicitly deferred while the core model stabilized:

  • How do you query facts by relationship instead of treating semantic triples as opaque text?
  • Can the library detect repeated patterns across conversations and promote them into durable facts?
  • How do you filter recall by access level — showing only what a given role is permitted to see?
  • What gets logged when a fact is created, updated, or deleted — and who made the change?
  • How do you export a user’s complete memory for backup, migration, or compliance requests?

All five follow the same pattern: optional, backward-compatible, and built on the existing abstractions rather than bypassing them.

A useful rule of thumb

Add memory to your AI app in this order: capture first, then recall, then augment, then isolate.

Capture gives you persistence. Recall gives you value. Augmentation gives you structure. Isolation gives you safety. Each step depends on the one before it, and each step can be deferred until you actually need it.

Why it stops here

Memori is 0.x by design. It started as an experiment — could a .NET library make AI memory feel like infrastructure rather than integration? The experiment worked well enough to ship, use in production, and learn from.

But the library is intentionally incomplete. The codebase stays at this stage so we can step back, watch how it gets used, and let real friction guide the next iteration. Early users surfaced the usual growing pains — naming collisions that required qualification workarounds, silent failures when attribution was missing, setup ceremony that could be streamlined, default thresholds that worked in demos but not production. Some have been addressed. Others wait because the right fix depends on actual usage patterns.

The Context Engineering series continues, but the Memori installments pause here. There will be a fourth when the next major version takes shape — informed by what this 0.x experiment taught us. Until then, the repo is open, the issues are public, and the abstractions are stable enough to build on.

For a closer look at the code and examples, check out the Memori repo: https://github.com/khurram-uworx/Memori

NuGet: https://www.nuget.org/packages/Memori

Getting started: https://github.com/khurram-uworx/Memori/blob/main/GETTING-STARTED.md

Architecture and design notes: https://github.com/khurram-uworx/Memori/blob/main/ARCHITECTURE.md

Phase 3 roadmap: https://github.com/khurram-uworx/Memori/blob/main/docs/PHASE3.md