diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs
index 9fed99bd73..61ce3fa75e 100644
--- a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs
+++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs
@@ -13,9 +13,10 @@ namespace Aevatar.GAgents.UserMemory;
/// Actor ID: user-memory-{userId} (user-scoped).
///
/// Eviction policy (runs inside ):
-/// 1. When adding an entry that would exceed ,
-/// evict the oldest entry in the same category first.
-/// 2. If no same-category entry remains, evict the globally oldest entry.
+/// 1. Without a retention policy, preserve the legacy same-category-first behavior.
+/// 2. With a policy, enforce the added category's cap before the global cap.
+/// 3. At the global cap, higher-ranked categories are evicted first and rank ties
+/// retain the legacy same-category-first order.
///
///
[GAgent("user.memory")]
@@ -27,6 +28,8 @@ public sealed class UserMemoryGAgent : GAgentBase, IProjectedAc
public static string ProjectionKind => "user-memory";
internal const int MaxEntries = 50;
+ private const int DefaultEvictionRank = 100;
+ private const int MaxEvictionRank = 1000;
[EventHandler(EndpointName = "addMemoryEntry")]
public async Task HandleAddUserMemoryEntry(AddUserMemoryEntryCommand command)
@@ -70,6 +73,31 @@ public async Task HandleClearUserMemoryEntries(ClearUserMemoryEntriesCommand com
await PersistDomainEventAsync(new MemoryEntriesClearedEvent());
}
+ [EventHandler(EndpointName = "replaceRetentionPolicy")]
+ public async Task HandleReplaceRetentionPolicy(ReplaceUserMemoryRetentionPolicyCommand command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ if (command.ExpectedStateVersion < 0)
+ throw new InvalidOperationException("user_memory_expected_state_version_invalid");
+
+ var mutationId = NormalizeMutationId(command.MutationId);
+ var replacedEvent = BuildRetentionPolicyReplacedEvent(State, command, mutationId);
+ if (string.Equals(State.LastRetentionPolicyMutationId, mutationId, StringComparison.Ordinal))
+ {
+ if (PolicyMatchesState(State.RetentionPolicy, replacedEvent.Policy))
+ return;
+
+ throw new InvalidOperationException("user_memory_policy_mutation_conflict");
+ }
+
+ var currentVersion = EventSourcing?.CurrentVersion
+ ?? throw new InvalidOperationException("user_memory_event_sourcing_unavailable");
+ if (command.ExpectedStateVersion != currentVersion)
+ throw new InvalidOperationException("user_memory_expected_state_version_conflict");
+
+ await PersistDomainEventAsync(replacedEvent);
+ }
+
protected override async Task OnActivateAsync(CancellationToken ct)
{
await base.OnActivateAsync(ct);
@@ -83,47 +111,116 @@ protected override UserMemoryState TransitionState(
.On(ApplyAdded)
.On(ApplyRemoved)
.On(ApplyCleared)
+ .On(ApplyRetentionPolicyReplaced)
.OrCurrent();
}
+ // Implement (issue #3528):
+ // Behavior: Policy replacement is validated and revisioned inside the owning actor state.
+ // Why this shape: Replay must derive retention from committed facts without external policy reads.
+ public static UserMemoryRetentionPolicyReplacedEvent BuildRetentionPolicyReplacedEvent(
+ UserMemoryState state,
+ ReplaceUserMemoryRetentionPolicyCommand command,
+ string? normalizedMutationId = null)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+ ArgumentNullException.ThrowIfNull(command);
+
+ var policy = new UserMemoryRetentionPolicy
+ {
+ PolicyRevision = (state.RetentionPolicy?.PolicyRevision ?? 0) + 1,
+ };
+ policy.Rules.AddRange(NormalizeRules(command.Rules));
+ return new UserMemoryRetentionPolicyReplacedEvent
+ {
+ Policy = policy,
+ MutationId = normalizedMutationId ?? NormalizeMutationId(command.MutationId),
+ };
+ }
+
private static UserMemoryState ApplyAdded(
UserMemoryState state, MemoryEntryAddedEvent evt)
{
var next = state.Clone();
next.Entries.Add(evt.Entry.Clone());
- // Eviction: enforce global cap.
- // Priority: evict oldest in same category first, then globally oldest.
+ if (next.RetentionPolicy is not null)
+ EnforceCategoryCap(next, evt.Entry);
+
while (next.Entries.Count > MaxEntries)
{
- var category = evt.Entry.Category;
- var oldestSameCategory = next.Entries
- .Where(e => e.Category == category
- && !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal))
- .OrderBy(e => e.CreatedAtMs)
- .FirstOrDefault();
+ var evicted = next.RetentionPolicy is null
+ ? SelectLegacyEvictionCandidate(next.Entries, evt.Entry)
+ : SelectPolicyEvictionCandidate(next, evt.Entry);
+ if (evicted is null)
+ break;
- if (oldestSameCategory is not null)
- {
- next.Entries.Remove(oldestSameCategory);
- }
- else
- {
- var globallyOldest = next.Entries
- .Where(e => !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal))
- .OrderBy(e => e.CreatedAtMs)
- .FirstOrDefault();
-
- if (globallyOldest is not null)
- next.Entries.Remove(globallyOldest);
- else
- break;
- }
+ next.Entries.Remove(evicted);
}
return next;
}
+ private static void EnforceCategoryCap(UserMemoryState state, UserMemoryEntryProto addedEntry)
+ {
+ var rule = state.RetentionPolicy!.Rules.FirstOrDefault(candidate =>
+ candidate.Category == addedEntry.Category);
+ if (rule is null || rule.MaxEntries == 0)
+ return;
+
+ while (state.Entries.Count(entry => entry.Category == addedEntry.Category) > rule.MaxEntries)
+ {
+ var oldest = state.Entries
+ .Where(entry => entry.Category == addedEntry.Category &&
+ !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
+ .OrderBy(entry => entry.CreatedAtMs)
+ .FirstOrDefault();
+ if (oldest is null)
+ break;
+
+ state.Entries.Remove(oldest);
+ }
+ }
+
+ // Implement (issue #3528):
+ // Behavior: Global eviction ranks categories, then preserves legacy ordering for equal ranks.
+ // Why this shape: It protects low-rank categories without changing tie behavior or evicting the new entry.
+ private static UserMemoryEntryProto? SelectPolicyEvictionCandidate(
+ UserMemoryState state,
+ UserMemoryEntryProto addedEntry)
+ {
+ var candidates = state.Entries
+ .Where(entry => !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
+ .ToArray();
+ if (candidates.Length == 0)
+ return null;
+
+ var highestRank = candidates.Max(entry => ResolveEvictionRank(state.RetentionPolicy!, entry.Category));
+ var highestRankCandidates = candidates
+ .Where(entry => ResolveEvictionRank(state.RetentionPolicy!, entry.Category) == highestRank)
+ .ToArray();
+ return SelectLegacyEvictionCandidate(highestRankCandidates, addedEntry);
+ }
+
+ private static UserMemoryEntryProto? SelectLegacyEvictionCandidate(
+ IEnumerable entries,
+ UserMemoryEntryProto addedEntry) =>
+ entries
+ .Where(entry => entry.Category == addedEntry.Category &&
+ !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
+ .OrderBy(entry => entry.CreatedAtMs)
+ .FirstOrDefault()
+ ?? entries
+ .Where(entry => !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
+ .OrderBy(entry => entry.CreatedAtMs)
+ .FirstOrDefault();
+
+ private static int ResolveEvictionRank(
+ UserMemoryRetentionPolicy policy,
+ UserMemoryCategory category) =>
+ policy.Rules.FirstOrDefault(rule => rule.Category == category)?.EvictionRank
+ ?? DefaultEvictionRank;
+
private static UserMemoryState ApplyRemoved(
UserMemoryState state, MemoryEntryRemovedEvent evt)
{
@@ -145,6 +242,51 @@ private static UserMemoryState ApplyCleared(
return next;
}
+ private static UserMemoryState ApplyRetentionPolicyReplaced(
+ UserMemoryState state,
+ UserMemoryRetentionPolicyReplacedEvent evt)
+ {
+ var next = state.Clone();
+ next.RetentionPolicy = evt.Policy.Clone();
+ next.LastRetentionPolicyMutationId = evt.MutationId;
+ return next;
+ }
+
+ private static IReadOnlyList NormalizeRules(
+ IEnumerable rules)
+ {
+ var normalized = new List();
+ var categories = new HashSet();
+ foreach (var rule in rules ?? [])
+ {
+ if (!Enum.IsDefined(rule.Category) || rule.Category == UserMemoryCategory.Unspecified)
+ throw new InvalidOperationException("user_memory_policy_category_invalid");
+ if (!categories.Add(rule.Category))
+ throw new InvalidOperationException("user_memory_policy_category_duplicate");
+ if (rule.MaxEntries is < 0 or > MaxEntries)
+ throw new InvalidOperationException("user_memory_policy_max_entries_invalid");
+ if (rule.EvictionRank is < 0 or > MaxEvictionRank)
+ throw new InvalidOperationException("user_memory_policy_eviction_rank_invalid");
+
+ normalized.Add(rule.Clone());
+ }
+
+ return normalized.OrderBy(static rule => rule.Category).ToArray();
+ }
+
+ private static bool PolicyMatchesState(
+ UserMemoryRetentionPolicy? statePolicy,
+ UserMemoryRetentionPolicy policy) =>
+ statePolicy is not null && statePolicy.Rules.SequenceEqual(policy.Rules);
+
+ private static string NormalizeMutationId(string? mutationId)
+ {
+ var normalized = mutationId?.Trim() ?? string.Empty;
+ if (normalized.Length == 0)
+ throw new InvalidOperationException("user_memory_policy_mutation_id_invalid");
+ return normalized;
+ }
+
private static void ValidateEntry(UserMemoryEntryProto? entry)
{
if (entry is null)
diff --git a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto
index 5d07aebd8e..d706d00d98 100644
--- a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto
+++ b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto
@@ -26,8 +26,21 @@ message UserMemoryEntryProto {
int64 updated_at_ms = 6;
}
+message UserMemoryCategoryRetentionRule {
+ UserMemoryCategory category = 1;
+ int32 max_entries = 2;
+ int32 eviction_rank = 3;
+}
+
+message UserMemoryRetentionPolicy {
+ repeated UserMemoryCategoryRetentionRule rules = 1;
+ int64 policy_revision = 2;
+}
+
message UserMemoryState {
repeated UserMemoryEntryProto entries = 1;
+ UserMemoryRetentionPolicy retention_policy = 2;
+ string last_retention_policy_mutation_id = 3;
}
// ─── Commands ───
@@ -43,6 +56,12 @@ message RemoveUserMemoryEntryCommand {
message ClearUserMemoryEntriesCommand {
}
+message ReplaceUserMemoryRetentionPolicyCommand {
+ repeated UserMemoryCategoryRetentionRule rules = 1;
+ int64 expected_state_version = 2;
+ string mutation_id = 3;
+}
+
// ─── Events ───
message MemoryEntryAddedEvent {
@@ -55,3 +74,8 @@ message MemoryEntryRemovedEvent {
message MemoryEntriesClearedEvent {
}
+
+message UserMemoryRetentionPolicyReplacedEvent {
+ UserMemoryRetentionPolicy policy = 1;
+ string mutation_id = 2;
+}
diff --git a/docs/canon/conversation-context-and-memory.md b/docs/canon/conversation-context-and-memory.md
index af21a4f52c..79589f3e77 100644
--- a/docs/canon/conversation-context-and-memory.md
+++ b/docs/canon/conversation-context-and-memory.md
@@ -33,7 +33,7 @@ route helper 和测试不得假设它们相等,也不得从 actor ID 前缀推
| Execution state | 从 typed admission/start 到 terminal/reconciled;只覆盖该 turn、session 或 run | 只保留恢复、幂等和终态查询所需的 actor-owned waterline;例如 `RoleGAgent` 只跟踪有界的已完成 session,NyxID turn actor 只保留有界 delivery evidence | 执行 owner 在 terminal/delivery 已安全后按自己的 typed policy 清理;不得把 checkpoint 复制到 transcript 或 user memory |
| Prompt context | 单次 LLM call 或当前执行 turn | 受 message/token/character budget 限制;调用结束即可丢弃 | 构建它的执行模块;截断只改变下一次调用输入,不删除任何权威事实 |
| Conversation transcript | conversation 初始化后持续存在,并可继续 append | 按 #3141:所有 committed turns 在显式删除整个 conversation 前可查询;无 per-turn TTL、silent rolling eviction 或隐式 archive | `ChatConversationGAgent` 通过 typed whole-conversation deletion fact 清理;projection 只物化该事实 |
-| User memory | 用户 scope 存续期间跨 conversation 存在 | 当前 actor 上限为 50 条;新增超限时优先淘汰同 category 最旧项,再淘汰全局最旧项;也可显式 remove/clear | `UserMemoryGAgent` 在 command handler 内决定 eviction/remove/clear 并提交 event;prompt builder 和 query adapter不得清理 |
+| User memory | 用户 scope 存续期间跨 conversation 存在 | actor 保持 50 条全局硬上限;未配置 retention policy 时沿用“同 category 最旧、再全局最旧”,配置后先执行新增 category 的 `max_entries` cap,再按 `eviction_rank` 从高到低执行全局 eviction,rank 相同时回退旧顺序;cap 不预留空位 | `UserMemoryGAgent` 通过带 revision、CAS 与 mutation 幂等的 typed policy command/event 持有 retention policy,并在状态转换内决定 eviction/remove/clear;prompt builder 和 query adapter 不得清理 |
如果未来需要 transcript archive、user-memory 向量检索或不同 retention,必须先定义新的
owner、typed lifecycle 和正式 query contract;不得在 prompt 截断或 query adapter 中静默实现。
@@ -59,9 +59,11 @@ read is redacted, tombstoned, expired, over budget, or temporarily unavailable.
`InitializeChatConversationCommand`、`AppendChatTurnCommand` 及其 domain events。
3. User memory 使用 `UserMemoryState`、typed `UserMemoryCategory`、typed
`UserMemorySource`,以及 `AddUserMemoryEntryCommand`、
- `RemoveUserMemoryEntryCommand`、`ClearUserMemoryEntriesCommand`。actor 校验 command,
- 再提交 `MemoryEntryAddedEvent`、`MemoryEntryRemovedEvent` 或
- `MemoryEntriesClearedEvent`;domain event 不再冒充 command。
+ `RemoveUserMemoryEntryCommand`、`ClearUserMemoryEntriesCommand` 与
+ `ReplaceUserMemoryRetentionPolicyCommand`。actor 校验 command,再提交
+ `MemoryEntryAddedEvent`、`MemoryEntryRemovedEvent`、`MemoryEntriesClearedEvent` 或
+ `UserMemoryRetentionPolicyReplacedEvent`;domain event 不再冒充 command,retention
+ policy 不从 conversation profile 或 query path 注入。
4. Prompt context 通过 typed LLM request/control 字段传递;`user_memory_prompt` 是已经
派生、受限长控制的 prompt 输入,不是 user-memory persistence contract。只有最终文本
拼装可以是字符串;category、source、identity、control 和 recovery policy 不得降级到
diff --git a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs
index f28e65d29d..c998e25357 100644
--- a/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs
+++ b/src/Aevatar.Studio.Application.Abstractions/Studio/Abstractions/IUserMemoryQueryPort.cs
@@ -39,15 +39,31 @@ public sealed record UserMemoryEntrySnapshot(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
+public sealed record UserMemoryCategoryRetentionRule(
+ UserMemoryCategory Category,
+ int MaxEntries,
+ int EvictionRank);
+
+public sealed record UserMemoryRetentionPolicy(
+ IReadOnlyList Rules);
+
public sealed record UserMemorySnapshot(
UserMemoryOwnerKey Owner,
long StateVersion,
- IReadOnlyList Entries)
+ IReadOnlyList Entries,
+ UserMemoryRetentionPolicy? RetentionPolicy = null,
+ long PolicyRevision = 0)
{
public static UserMemorySnapshot Empty(UserMemoryOwnerKey owner) =>
new(owner, 0, []);
}
+public sealed record ReplaceUserMemoryRetentionPolicy(
+ UserMemoryOwnerKey Owner,
+ IReadOnlyList Rules,
+ long ExpectedStateVersion,
+ string MutationId);
+
///
/// Reads the per-user current-state replica materialized from committed
/// UserMemoryGAgent facts. Implementations must not activate actors,
@@ -57,3 +73,14 @@ public interface IUserMemoryQueryPort
{
Task GetAsync(CancellationToken ct = default);
}
+
+///
+/// Replaces actor-owned user-memory retention policy through the standard
+/// command dispatch path. Acceptance does not imply commit or read-model visibility.
+///
+public interface IUserMemoryRetentionPolicyCommandPort
+{
+ Task ReplaceAsync(
+ ReplaceUserMemoryRetentionPolicy command,
+ CancellationToken ct = default);
+}
diff --git a/src/Aevatar.Studio.Hosting/Controllers/UserMemoryRetentionPolicyController.cs b/src/Aevatar.Studio.Hosting/Controllers/UserMemoryRetentionPolicyController.cs
new file mode 100644
index 0000000000..4806da267c
--- /dev/null
+++ b/src/Aevatar.Studio.Hosting/Controllers/UserMemoryRetentionPolicyController.cs
@@ -0,0 +1,98 @@
+using System.Text.Json.Serialization;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Aevatar.Studio.Hosting.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/user-memory/retention-policy")]
+public sealed class UserMemoryRetentionPolicyController : ControllerBase
+{
+ private readonly IUserMemoryRetentionPolicyCommandPort _commandPort;
+ private readonly IAppScopeResolver _scopeResolver;
+
+ public UserMemoryRetentionPolicyController(
+ IUserMemoryRetentionPolicyCommandPort commandPort,
+ IAppScopeResolver scopeResolver)
+ {
+ _commandPort = commandPort ?? throw new ArgumentNullException(nameof(commandPort));
+ _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver));
+ }
+
+ [HttpPut]
+ public async Task> Replace(
+ [FromBody] ReplaceUserMemoryRetentionPolicyRequest? request,
+ CancellationToken ct)
+ {
+ if (request is null)
+ return BadRequest(new { message = "Request body is required." });
+ if (request.ExpectedStateVersion is null)
+ return BadRequest(new { message = "expectedStateVersion is required." });
+
+ try
+ {
+ var owner = UserMemoryOwnerKey.ForScope(_scopeResolver.ResolveScopeIdOrDefault());
+ var receipt = await _commandPort.ReplaceAsync(
+ request.ToApplication(owner),
+ ct).ConfigureAwait(false);
+ return Accepted(UserMemoryRetentionPolicySaveReceiptResponse.FromApplication(receipt));
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ return BadRequest(new { message = exception.Message });
+ }
+ }
+}
+
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record ReplaceUserMemoryRetentionPolicyRequest(
+ [property: JsonPropertyName("rules")] IReadOnlyList? Rules,
+ [property: JsonPropertyName("expectedStateVersion")] long? ExpectedStateVersion,
+ [property: JsonPropertyName("mutationId")] string? MutationId)
+{
+ public ReplaceUserMemoryRetentionPolicy ToApplication(UserMemoryOwnerKey owner) => new(
+ owner,
+ (Rules ?? []).Select(static rule =>
+ rule?.ToApplication() ?? throw new InvalidOperationException("Retention rule is required."))
+ .ToArray(),
+ ExpectedStateVersion ?? throw new InvalidOperationException("expectedStateVersion is required."),
+ MutationId ?? string.Empty);
+}
+
+[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
+public sealed record UserMemoryCategoryRetentionRuleRequest(
+ [property: JsonPropertyName("category")] string? Category,
+ [property: JsonPropertyName("maxEntries")] int MaxEntries,
+ [property: JsonPropertyName("evictionRank")] int EvictionRank)
+{
+ public UserMemoryCategoryRetentionRule ToApplication() => new(
+ Category?.Trim().ToLowerInvariant() switch
+ {
+ "preference" => UserMemoryCategory.Preference,
+ "instruction" => UserMemoryCategory.Instruction,
+ "context" => UserMemoryCategory.Context,
+ _ => UserMemoryCategory.Unspecified,
+ },
+ MaxEntries,
+ EvictionRank);
+}
+
+public sealed record UserMemoryRetentionPolicySaveReceiptResponse(
+ [property: JsonPropertyName("accepted")] bool Accepted,
+ [property: JsonPropertyName("commandId")] string CommandId,
+ [property: JsonPropertyName("ackStage")] string AckStage,
+ [property: JsonPropertyName("actorId")] string ActorId,
+ [property: JsonPropertyName("correlationId")] string CorrelationId,
+ [property: JsonPropertyName("ackedAtUtc")] DateTimeOffset AckedAtUtc)
+{
+ public static UserMemoryRetentionPolicySaveReceiptResponse FromApplication(
+ UserConfigSaveReceipt receipt) => new(
+ receipt.Accepted,
+ receipt.CommandId,
+ receipt.AckStage,
+ receipt.ActorId,
+ receipt.CorrelationId,
+ receipt.AckedAtUtc);
+}
diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserMemoryRetentionPolicyCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserMemoryRetentionPolicyCommandService.cs
new file mode 100644
index 0000000000..92b6a7db1c
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserMemoryRetentionPolicyCommandService.cs
@@ -0,0 +1,100 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgents.UserMemory;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Google.Protobuf.WellKnownTypes;
+using ApplicationRetentionRule = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategoryRetentionRule;
+using ActorRetentionRule = Aevatar.GAgents.UserMemory.UserMemoryCategoryRetentionRule;
+using ActorUserMemoryCategory = Aevatar.GAgents.UserMemory.UserMemoryCategory;
+
+namespace Aevatar.Studio.Projection.CommandServices;
+
+internal sealed class ActorDispatchUserMemoryRetentionPolicyCommandService
+ : IUserMemoryRetentionPolicyCommandPort
+{
+ private const string ActorIdPrefix = "user-memory-";
+ private const string DirectRoute = "aevatar.studio.projection.user-memory-retention-policy";
+
+ private readonly IStudioActorBootstrap _bootstrap;
+ private readonly IActorDispatchPort _dispatchPort;
+
+ public ActorDispatchUserMemoryRetentionPolicyCommandService(
+ IStudioActorBootstrap bootstrap,
+ IActorDispatchPort dispatchPort)
+ {
+ _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
+ _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort));
+ }
+
+ public async Task ReplaceAsync(
+ ReplaceUserMemoryRetentionPolicy command,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ if (command.ExpectedStateVersion < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(command.ExpectedStateVersion),
+ "Expected state version must be non-negative.");
+ }
+ if (string.IsNullOrWhiteSpace(command.Owner.ScopeId))
+ throw new ArgumentException("User-memory owner scope is required.", nameof(command.Owner));
+ if (command.Rules is null)
+ throw new ArgumentNullException(nameof(command.Rules));
+
+ var payload = MapCommand(command);
+ _ = UserMemoryGAgent.BuildRetentionPolicyReplacedEvent(new UserMemoryState(), payload);
+
+ var actorId = ActorIdPrefix + command.Owner.ScopeId;
+ var actor = await _bootstrap.EnsureAsync(actorId, ct);
+ var envelope = new EventEnvelope
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ Payload = Any.Pack(payload),
+ Route = EnvelopeRouteSemantics.CreateDirect(DirectRoute, actor.Id),
+ };
+
+ var admission = await _dispatchPort.DispatchAsync(actor.Id, envelope, ct).ConfigureAwait(false);
+ return new UserConfigSaveReceipt(
+ admission.Accepted,
+ admission.CommandId,
+ admission.Accepted
+ ? UserConfigCommandAckStage.Accepted
+ : UserConfigCommandAckStage.AdmissionRejected,
+ admission.ActorId,
+ admission.CorrelationId,
+ admission.AckedAt);
+ }
+
+ private static ReplaceUserMemoryRetentionPolicyCommand MapCommand(
+ ReplaceUserMemoryRetentionPolicy command)
+ {
+ var payload = new ReplaceUserMemoryRetentionPolicyCommand
+ {
+ ExpectedStateVersion = command.ExpectedStateVersion,
+ MutationId = command.MutationId ?? string.Empty,
+ };
+ payload.Rules.AddRange(command.Rules.Select(MapRule));
+ return payload;
+ }
+
+ private static ActorRetentionRule MapRule(ApplicationRetentionRule rule)
+ {
+ ArgumentNullException.ThrowIfNull(rule);
+ return new ActorRetentionRule
+ {
+ Category = rule.Category switch
+ {
+ Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Preference =>
+ ActorUserMemoryCategory.Preference,
+ Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Instruction =>
+ ActorUserMemoryCategory.Instruction,
+ Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Context =>
+ ActorUserMemoryCategory.Context,
+ _ => ActorUserMemoryCategory.Unspecified,
+ },
+ MaxEntries = rule.MaxEntries,
+ EvictionRank = rule.EvictionRank,
+ };
+ }
+}
diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
index e8ad395b16..5a5b2799f1 100644
--- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
@@ -21,6 +21,7 @@
using Aevatar.GAgents.ContentArtifacts;
using Aevatar.GAgents.WorkOrder;
using Aevatar.GAgents.WorkflowDelivery;
+using Aevatar.GAgents.UserMemory;
using Aevatar.Studio.Workspace;
using Aevatar.GAgentService.Abstractions.Schedules.Authorization;
using Microsoft.Extensions.Configuration;
@@ -51,6 +52,7 @@ public static IServiceCollection AddStudioProjectionComponents(
services.AddCqrsCore();
services.AddAevatarAgentKindRegistry(builder => builder.ScanAssemblies(
typeof(Aevatar.GAgents.UserConfig.UserConfigGAgent).Assembly,
+ typeof(UserMemoryGAgent).Assembly,
typeof(Aevatar.GAgents.StudioMember.StudioMemberGAgent).Assembly,
typeof(Aevatar.GAgents.StudioTeam.StudioTeamGAgent).Assembly,
typeof(ContentArtifactGAgent).Assembly,
@@ -318,6 +320,9 @@ public static IServiceCollection AddStudioProjectionComponents(
services.TryAddSingleton<
ILLMModelCatalogPolicyCommandPort,
ActorDispatchLLMModelCatalogPolicyCommandService>();
+ services.TryAddSingleton<
+ IUserMemoryRetentionPolicyCommandPort,
+ ActorDispatchUserMemoryRetentionPolicyCommandService>();
services.TryAddSingleton();
services.TryAddSingleton<
IStudioWorkflowScheduleProvisioningCommandPort,
diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs
index 0c8a323083..4a9fb66e0f 100644
--- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs
+++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserMemoryQueryPort.cs
@@ -3,6 +3,8 @@
using Aevatar.Studio.Application.Studio.Abstractions;
using Aevatar.Studio.Projection.ReadModels;
using ApplicationUserMemoryCategory = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory;
+using ApplicationRetentionPolicy = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryRetentionPolicy;
+using ApplicationRetentionRule = Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategoryRetentionRule;
using ApplicationUserMemorySource = Aevatar.Studio.Application.Studio.Abstractions.UserMemorySource;
using ActorUserMemoryCategory = Aevatar.GAgents.UserMemory.UserMemoryCategory;
using ActorUserMemorySource = Aevatar.GAgents.UserMemory.UserMemorySource;
@@ -50,8 +52,22 @@ public async Task GetAsync(CancellationToken ct = default)
DateTimeOffset.FromUnixTimeMilliseconds(entry.UpdatedAtMs)))
.ToList()
.AsReadOnly();
+ var retentionPolicy = state.RetentionPolicy is null
+ ? null
+ : new ApplicationRetentionPolicy(state.RetentionPolicy.Rules
+ .Select(static rule => new ApplicationRetentionRule(
+ MapCategory(rule.Category),
+ rule.MaxEntries,
+ rule.EvictionRank))
+ .ToList()
+ .AsReadOnly());
- return new UserMemorySnapshot(owner, document.StateVersion, entries);
+ return new UserMemorySnapshot(
+ owner,
+ document.StateVersion,
+ entries,
+ retentionPolicy,
+ state.RetentionPolicy?.PolicyRevision ?? 0);
}
private static bool IsReadable(UserMemoryEntryProto entry) =>
diff --git a/test/Aevatar.Studio.Tests/ActorDispatchUserMemoryRetentionPolicyCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchUserMemoryRetentionPolicyCommandServiceTests.cs
new file mode 100644
index 0000000000..e88811821b
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/ActorDispatchUserMemoryRetentionPolicyCommandServiceTests.cs
@@ -0,0 +1,142 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgents.UserMemory;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Projection.CommandServices;
+using Aevatar.Studio.Projection.DependencyInjection;
+using FluentAssertions;
+using Google.Protobuf;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aevatar.Studio.Tests;
+
+public sealed class ActorDispatchUserMemoryRetentionPolicyCommandServiceTests
+{
+ [Fact]
+ public async Task ReplaceAsync_ShouldDispatchTypedScopeCommandAndReturnHonestReceipt()
+ {
+ var bootstrap = new RecordingBootstrap();
+ var ackedAt = DateTimeOffset.Parse("2026-08-25T08:00:00Z");
+ var dispatch = new RecordingDispatchPort(new DispatchAdmission(
+ true,
+ "command-alpha",
+ ackedAt,
+ "user-memory-scope-alpha",
+ "correlation-alpha"));
+ var service = new ActorDispatchUserMemoryRetentionPolicyCommandService(bootstrap, dispatch);
+
+ var receipt = await service.ReplaceAsync(new ReplaceUserMemoryRetentionPolicy(
+ UserMemoryOwnerKey.ForScope("scope-alpha"),
+ [
+ new(
+ Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Preference,
+ 8,
+ 20),
+ ],
+ 7,
+ "mutation-alpha"));
+
+ bootstrap.ActorIds.Should().ContainSingle("user-memory-scope-alpha");
+ var dispatched = dispatch.Dispatches.Should().ContainSingle().Subject;
+ dispatched.ActorId.Should().Be("user-memory-scope-alpha");
+ dispatched.Envelope.Route.PublisherActorId.Should()
+ .Be("aevatar.studio.projection.user-memory-retention-policy");
+ var payload = dispatched.Envelope.Payload.Unpack();
+ payload.ExpectedStateVersion.Should().Be(7);
+ payload.MutationId.Should().Be("mutation-alpha");
+ payload.Rules.Should().ContainSingle();
+ payload.Rules[0].Category.Should().Be(Aevatar.GAgents.UserMemory.UserMemoryCategory.Preference);
+ payload.Rules[0].MaxEntries.Should().Be(8);
+ payload.Rules[0].EvictionRank.Should().Be(20);
+ receipt.Should().Be(new UserConfigSaveReceipt(
+ true,
+ "command-alpha",
+ UserConfigCommandAckStage.Accepted,
+ "user-memory-scope-alpha",
+ "correlation-alpha",
+ ackedAt));
+ }
+
+ [Fact]
+ public async Task ReplaceAsync_WithInvalidRule_ShouldRejectBeforeActorBootstrap()
+ {
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = RecordingDispatchPort.Accepting();
+ var service = new ActorDispatchUserMemoryRetentionPolicyCommandService(bootstrap, dispatch);
+ var command = new ReplaceUserMemoryRetentionPolicy(
+ UserMemoryOwnerKey.ForScope("scope-alpha"),
+ [
+ new(
+ Aevatar.Studio.Application.Studio.Abstractions.UserMemoryCategory.Unspecified,
+ 0,
+ 100),
+ ],
+ 0,
+ "mutation-invalid");
+
+ var act = () => service.ReplaceAsync(command);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("user_memory_policy_category_invalid");
+ bootstrap.ActorIds.Should().BeEmpty();
+ dispatch.Dispatches.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void AddStudioProjectionComponents_ShouldRegisterRetentionPolicyCommandPort()
+ {
+ var services = new ServiceCollection();
+
+ services.AddStudioProjectionComponents();
+
+ services.Should().Contain(descriptor =>
+ descriptor.ServiceType == typeof(IUserMemoryRetentionPolicyCommandPort) &&
+ descriptor.ImplementationType ==
+ typeof(ActorDispatchUserMemoryRetentionPolicyCommandService));
+ }
+
+ private sealed class RecordingBootstrap : IStudioActorBootstrap
+ {
+ public List ActorIds { get; } = [];
+
+ public Task EnsureAsync(string actorId, CancellationToken ct = default)
+ where TAgent : IAgent, IProjectedActor
+ {
+ ActorIds.Add(actorId);
+ return Task.FromResult(new StubActor(actorId));
+ }
+ }
+
+ private sealed class StubActor(string id) : IActor
+ {
+ public string Id { get; } = id;
+ public IAgent Agent => throw new NotSupportedException();
+ public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) =>
+ Task.CompletedTask;
+ public Task GetParentIdAsync() => Task.FromResult(null);
+ public Task> GetChildrenIdsAsync() =>
+ Task.FromResult>([]);
+ }
+
+ private sealed class RecordingDispatchPort(DispatchAdmission admission) : IActorDispatchPort
+ {
+ public List<(string ActorId, EventEnvelope Envelope)> Dispatches { get; } = [];
+
+ public static RecordingDispatchPort Accepting() => new(new DispatchAdmission(
+ true,
+ "command-alpha",
+ DateTimeOffset.Parse("2026-08-25T08:00:00Z"),
+ "user-memory-scope-alpha",
+ "correlation-alpha"));
+
+ public Task DispatchAsync(
+ string actorId,
+ EventEnvelope envelope,
+ CancellationToken ct = default)
+ {
+ Dispatches.Add((actorId, envelope));
+ return Task.FromResult(admission);
+ }
+ }
+}
diff --git a/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs b/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs
index 1724733b4c..6a638b59cc 100644
--- a/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs
+++ b/test/Aevatar.Studio.Tests/ProjectionUserMemoryQueryPortTests.cs
@@ -22,6 +22,19 @@ public async Task GetAsync_ShouldReadOwnerCurrentStateWithoutConflatingConversat
var owner = UserMemoryOwnerKey.ForScope("user-gamma");
var state = new ActorUserMemoryState
{
+ RetentionPolicy = new Aevatar.GAgents.UserMemory.UserMemoryRetentionPolicy
+ {
+ PolicyRevision = 4,
+ Rules =
+ {
+ new Aevatar.GAgents.UserMemory.UserMemoryCategoryRetentionRule
+ {
+ Category = ActorUserMemoryCategory.Preference,
+ MaxEntries = 8,
+ EvictionRank = 20,
+ },
+ },
+ },
Entries =
{
new ActorUserMemoryEntry
@@ -59,6 +72,9 @@ public async Task GetAsync_ShouldReadOwnerCurrentStateWithoutConflatingConversat
entry.Category.Should().Be(UserMemoryCategory.Preference);
entry.Source.Should().Be(UserMemorySource.Explicit);
entry.CreatedAt.Should().Be(DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_000));
+ snapshot.PolicyRevision.Should().Be(4);
+ snapshot.RetentionPolicy!.Rules.Should().ContainSingle().Which.Should().Be(
+ new UserMemoryCategoryRetentionRule(UserMemoryCategory.Preference, 8, 20));
}
[Fact]
diff --git a/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs b/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs
index c228294ea5..45ff21885f 100644
--- a/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs
+++ b/test/Aevatar.Studio.Tests/UserMemoryGAgentCommandTests.cs
@@ -107,6 +107,268 @@ await act.Should().ThrowAsync()
(await eventStore.GetEventsAsync(ActorId)).Should().BeEmpty();
}
+ [Fact]
+ public async Task ReplayWithoutPolicy_ShouldPreserveLegacyStateBytes()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ var context = Entry("context-oldest", UserMemoryCategory.Context, 1);
+ var preference = Entry("preference-old", UserMemoryCategory.Preference, 10);
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = context });
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = preference });
+ var instructions = Enumerable.Range(0, 48)
+ .Select(index => Entry($"instruction-{index}", UserMemoryCategory.Instruction, 100 + index))
+ .ToArray();
+ foreach (var instruction in instructions)
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = instruction });
+
+ var added = Entry("preference-new", UserMemoryCategory.Preference, 1_000);
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand { Entry = added });
+
+ var expected = new UserMemoryState();
+ expected.Entries.Add(context);
+ expected.Entries.AddRange(instructions);
+ expected.Entries.Add(added);
+ agent.State.RetentionPolicy.Should().BeNull();
+ agent.State.ToByteArray().Should().Equal(expected.ToByteArray());
+ }
+
+ [Fact]
+ public async Task PolicyEviction_ShouldEnforceAddedCategoryCap()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ await agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-cap",
+ Rule(UserMemoryCategory.Preference, maxEntries: 2, evictionRank: 100)));
+
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("preference-1", UserMemoryCategory.Preference, 1),
+ });
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("preference-2", UserMemoryCategory.Preference, 2),
+ });
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("preference-3", UserMemoryCategory.Preference, 3),
+ });
+
+ agent.State.Entries.Select(static entry => entry.Id)
+ .Should().Equal("preference-2", "preference-3");
+ }
+
+ [Fact]
+ public async Task PolicyEviction_ShouldEvictHigherRankBeforeOlderLowRankEntry()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ await agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-rank",
+ Rule(UserMemoryCategory.Preference, 0, 0),
+ Rule(UserMemoryCategory.Context, 0, 900)));
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("context-newer", UserMemoryCategory.Context, 1_000),
+ });
+ for (var index = 0; index < 49; index++)
+ {
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry($"preference-{index}", UserMemoryCategory.Preference, index + 1),
+ });
+ }
+
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("instruction-added", UserMemoryCategory.Instruction, 2_000),
+ });
+
+ agent.State.Entries.Should().HaveCount(50);
+ agent.State.Entries.Should().NotContain(entry => entry.Id == "context-newer");
+ agent.State.Entries.Should().Contain(entry => entry.Id == "preference-0");
+ }
+
+ [Fact]
+ public async Task PolicyEviction_WhenRanksTie_ShouldUseLegacySameCategoryOrder()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ await agent.HandleReplaceRetentionPolicy(PolicyCommand(0, "policy-tie"));
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("context-oldest", UserMemoryCategory.Context, 1),
+ });
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("preference-old", UserMemoryCategory.Preference, 100),
+ });
+ for (var index = 0; index < 48; index++)
+ {
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry($"instruction-{index}", UserMemoryCategory.Instruction, 200 + index),
+ });
+ }
+
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("preference-added", UserMemoryCategory.Preference, 2_000),
+ });
+
+ agent.State.Entries.Should().Contain(entry => entry.Id == "context-oldest");
+ agent.State.Entries.Should().NotContain(entry => entry.Id == "preference-old");
+ agent.State.Entries.Should().Contain(entry => entry.Id == "preference-added");
+ }
+
+ [Fact]
+ public async Task PolicyEviction_ShouldNeverEvictEntryBeingAdded()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ await agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-new-entry",
+ Rule(UserMemoryCategory.Preference, 0, 0),
+ Rule(UserMemoryCategory.Context, 0, 1_000)));
+ for (var index = 0; index < 50; index++)
+ {
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry($"preference-{index}", UserMemoryCategory.Preference, 100 + index),
+ });
+ }
+
+ await agent.HandleAddUserMemoryEntry(new AddUserMemoryEntryCommand
+ {
+ Entry = Entry("context-added", UserMemoryCategory.Context, 1),
+ });
+
+ agent.State.Entries.Should().Contain(entry => entry.Id == "context-added");
+ agent.State.Entries.Should().NotContain(entry => entry.Id == "preference-0");
+ }
+
+ [Fact]
+ public async Task ReplacePolicy_ShouldApplyCasRevisionAndMutationIdempotency()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ var initial = PolicyCommand(
+ 0,
+ " policy-alpha ",
+ Rule(UserMemoryCategory.Context, 5, 10),
+ Rule(UserMemoryCategory.Preference, 2, 900));
+
+ await agent.HandleReplaceRetentionPolicy(initial);
+
+ agent.EventSourcing!.CurrentVersion.Should().Be(1);
+ agent.State.RetentionPolicy.PolicyRevision.Should().Be(1);
+ agent.State.RetentionPolicy.Rules.Select(static rule => rule.Category)
+ .Should().Equal(UserMemoryCategory.Preference, UserMemoryCategory.Context);
+ agent.State.LastRetentionPolicyMutationId.Should().Be("policy-alpha");
+
+ var retry = PolicyCommand(
+ 0,
+ "policy-alpha",
+ Rule(UserMemoryCategory.Preference, 2, 900),
+ Rule(UserMemoryCategory.Context, 5, 10));
+ await agent.HandleReplaceRetentionPolicy(retry);
+ agent.EventSourcing.CurrentVersion.Should().Be(1);
+
+ var conflictingMutation = () => agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-alpha",
+ Rule(UserMemoryCategory.Context, 4, 10)));
+ await conflictingMutation.Should().ThrowAsync()
+ .WithMessage("user_memory_policy_mutation_conflict");
+
+ var staleVersion = () => agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-beta",
+ Rule(UserMemoryCategory.Context, 4, 10)));
+ await staleVersion.Should().ThrowAsync()
+ .WithMessage("user_memory_expected_state_version_conflict");
+ agent.EventSourcing.CurrentVersion.Should().Be(1);
+
+ await agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 1,
+ "policy-beta",
+ Rule(UserMemoryCategory.Context, 4, 10)));
+ agent.State.RetentionPolicy.PolicyRevision.Should().Be(2);
+ agent.EventSourcing.CurrentVersion.Should().Be(2);
+ }
+
+ [Theory]
+ [InlineData(UserMemoryCategory.Unspecified, 0, 0, "user_memory_policy_category_invalid")]
+ [InlineData(UserMemoryCategory.Preference, -1, 0, "user_memory_policy_max_entries_invalid")]
+ [InlineData(UserMemoryCategory.Preference, 51, 0, "user_memory_policy_max_entries_invalid")]
+ [InlineData(UserMemoryCategory.Preference, 0, -1, "user_memory_policy_eviction_rank_invalid")]
+ [InlineData(UserMemoryCategory.Preference, 0, 1001, "user_memory_policy_eviction_rank_invalid")]
+ public async Task ReplacePolicy_WithInvalidRule_ShouldRejectWithoutCommitting(
+ UserMemoryCategory category,
+ int maxEntries,
+ int evictionRank,
+ string error)
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ var act = () => agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-invalid",
+ Rule(category, maxEntries, evictionRank)));
+
+ await act.Should().ThrowAsync().WithMessage(error);
+ agent.EventSourcing!.CurrentVersion.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task ReplacePolicy_WithDuplicateCategory_ShouldRejectWithoutCommitting()
+ {
+ var agent = await CreateAgentAsync(new InMemoryEventStore());
+ var act = () => agent.HandleReplaceRetentionPolicy(PolicyCommand(
+ 0,
+ "policy-duplicate",
+ Rule(UserMemoryCategory.Preference, 2, 10),
+ Rule(UserMemoryCategory.Preference, 3, 20)));
+
+ await act.Should().ThrowAsync()
+ .WithMessage("user_memory_policy_category_duplicate");
+ agent.EventSourcing!.CurrentVersion.Should().Be(0);
+ }
+
+ private static ReplaceUserMemoryRetentionPolicyCommand PolicyCommand(
+ long expectedStateVersion,
+ string mutationId,
+ params UserMemoryCategoryRetentionRule[] rules)
+ {
+ var command = new ReplaceUserMemoryRetentionPolicyCommand
+ {
+ ExpectedStateVersion = expectedStateVersion,
+ MutationId = mutationId,
+ };
+ command.Rules.AddRange(rules);
+ return command;
+ }
+
+ private static UserMemoryCategoryRetentionRule Rule(
+ UserMemoryCategory category,
+ int maxEntries,
+ int evictionRank) => new()
+ {
+ Category = category,
+ MaxEntries = maxEntries,
+ EvictionRank = evictionRank,
+ };
+
+ private static UserMemoryEntryProto Entry(
+ string id,
+ UserMemoryCategory category,
+ long createdAtMs) => new()
+ {
+ Id = id,
+ Category = category,
+ Content = $"content-{id}",
+ Source = UserMemorySource.Explicit,
+ CreatedAtMs = createdAtMs,
+ UpdatedAtMs = createdAtMs,
+ };
+
private static EventEnvelope Envelope(IMessage payload) =>
new()
{
diff --git a/test/Aevatar.Studio.Tests/UserMemoryRetentionPolicyControllerTests.cs b/test/Aevatar.Studio.Tests/UserMemoryRetentionPolicyControllerTests.cs
new file mode 100644
index 0000000000..c985cb71ba
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/UserMemoryRetentionPolicyControllerTests.cs
@@ -0,0 +1,110 @@
+using System.Reflection;
+using Aevatar.Studio.Application.Studio.Abstractions;
+using Aevatar.Studio.Hosting.Controllers;
+using FluentAssertions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Aevatar.Studio.Tests;
+
+public sealed class UserMemoryRetentionPolicyControllerTests
+{
+ [Fact]
+ public void Controller_ShouldExposeAuthorizedPutRoute()
+ {
+ typeof(UserMemoryRetentionPolicyController)
+ .GetCustomAttribute()
+ .Should().NotBeNull();
+ typeof(UserMemoryRetentionPolicyController)
+ .GetCustomAttribute()!.Template
+ .Should().Be("api/user-memory/retention-policy");
+ typeof(UserMemoryRetentionPolicyController)
+ .GetMethod(nameof(UserMemoryRetentionPolicyController.Replace))!
+ .GetCustomAttribute()
+ .Should().NotBeNull();
+ }
+
+ [Fact]
+ public async Task Replace_ShouldUseAuthorizedScopeAndReturnAcceptedReceipt()
+ {
+ var ackedAt = DateTimeOffset.Parse("2026-08-25T08:00:00Z");
+ var commandPort = new RecordingCommandPort(new UserConfigSaveReceipt(
+ true,
+ "command-alpha",
+ UserConfigCommandAckStage.Accepted,
+ "user-memory-scope-alpha",
+ "correlation-alpha",
+ ackedAt));
+ var controller = new UserMemoryRetentionPolicyController(
+ commandPort,
+ new StubScopeResolver("scope-alpha"));
+ var request = new ReplaceUserMemoryRetentionPolicyRequest(
+ [new("preference", 8, 20)],
+ 7,
+ "mutation-alpha");
+
+ var response = await controller.Replace(request, CancellationToken.None);
+
+ var accepted = response.Result.Should().BeOfType().Subject;
+ accepted.Value.Should().Be(new UserMemoryRetentionPolicySaveReceiptResponse(
+ true,
+ "command-alpha",
+ UserConfigCommandAckStage.Accepted,
+ "user-memory-scope-alpha",
+ "correlation-alpha",
+ ackedAt));
+ var command = commandPort.Commands.Should().ContainSingle().Subject;
+ command.Owner.Should().Be(UserMemoryOwnerKey.ForScope("scope-alpha"));
+ command.ExpectedStateVersion.Should().Be(7);
+ command.MutationId.Should().Be("mutation-alpha");
+ command.Rules.Should().ContainSingle().Which.Should().Be(
+ new UserMemoryCategoryRetentionRule(UserMemoryCategory.Preference, 8, 20));
+ }
+
+ [Fact]
+ public async Task Replace_WithoutExpectedVersion_ShouldRejectBeforeDispatch()
+ {
+ var commandPort = new RecordingCommandPort(new UserConfigSaveReceipt(
+ true,
+ "command-alpha",
+ UserConfigCommandAckStage.Accepted,
+ "user-memory-scope-alpha",
+ "correlation-alpha",
+ DateTimeOffset.UtcNow));
+ var controller = new UserMemoryRetentionPolicyController(
+ commandPort,
+ new StubScopeResolver("scope-alpha"));
+
+ var response = await controller.Replace(
+ new ReplaceUserMemoryRetentionPolicyRequest([], null, "mutation-alpha"),
+ CancellationToken.None);
+
+ response.Result.Should().BeOfType();
+ commandPort.Commands.Should().BeEmpty();
+ }
+
+ private sealed class RecordingCommandPort(UserConfigSaveReceipt receipt)
+ : IUserMemoryRetentionPolicyCommandPort
+ {
+ public List Commands { get; } = [];
+
+ public Task ReplaceAsync(
+ ReplaceUserMemoryRetentionPolicy command,
+ CancellationToken ct = default)
+ {
+ Commands.Add(command);
+ return Task.FromResult(receipt);
+ }
+ }
+
+ private sealed class StubScopeResolver(string scopeId) : IAppScopeResolver
+ {
+ public AppScopeContext? Resolve(HttpContext? httpContext = null) =>
+ new(scopeId, "claim:scope_id");
+
+ public bool HasHttpRequestContext(HttpContext? httpContext = null) => true;
+
+ public bool HasAuthenticatedRequestWithoutScope(HttpContext? httpContext = null) => false;
+ }
+}