diff --git a/CognitiveMesh.sln b/CognitiveMesh.sln index 811f8110..af6bcbdb 100644 --- a/CognitiveMesh.sln +++ b/CognitiveMesh.sln @@ -93,6 +93,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResearchAnalysis", "src\Bus EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ValueGeneration", "src\BusinessApplications\ValueGeneration\ValueGeneration.csproj", "{55ABF7B2-A718-4A0D-9B90-479D9D549E7D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolicyStore", "src\FoundationLayer\PolicyStore\PolicyStore.csproj", "{A0B1C2D3-E4F5-6789-ABCD-100000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SelfHealing", "src\AgencyLayer\SelfHealing\SelfHealing.csproj", "{A0B1C2D3-E4F5-6789-ABCD-100000000002}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FoundationLayer", "FoundationLayer", "{A0B1C2D3-E4F5-6789-ABCD-100000000003}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolicyStore.Tests", "tests\FoundationLayer\PolicyStore\PolicyStore.Tests.csproj", "{A0B1C2D3-E4F5-6789-ABCD-100000000004}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SelfHealing.Tests", "tests\AgencyLayer\SelfHealing\SelfHealing.Tests.csproj", "{A0B1C2D3-E4F5-6789-ABCD-100000000005}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -559,6 +569,54 @@ Global {55ABF7B2-A718-4A0D-9B90-479D9D549E7D}.Release|x64.Build.0 = Release|Any CPU {55ABF7B2-A718-4A0D-9B90-479D9D549E7D}.Release|x86.ActiveCfg = Release|Any CPU {55ABF7B2-A718-4A0D-9B90-479D9D549E7D}.Release|x86.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Debug|x64.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Debug|x86.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Release|x64.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Release|x64.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Release|x86.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000001}.Release|x86.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Debug|x64.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Debug|x86.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Release|x64.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Release|x64.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Release|x86.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000002}.Release|x86.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Debug|x64.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Debug|x86.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Release|x64.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Release|x64.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Release|x86.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000004}.Release|x86.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Debug|x64.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Debug|x86.Build.0 = Debug|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Release|x64.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Release|x64.Build.0 = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Release|x86.ActiveCfg = Release|Any CPU + {A0B1C2D3-E4F5-6789-ABCD-100000000005}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -607,5 +665,10 @@ Global {4F909B40-E2A4-4344-BEF8-55555CEEA7A6} = {DDB468F9-AACD-4EAF-9EBB-8DB0972963EB} {F9A0F61C-2CE5-4795-9040-9B01A6EC98B2} = {DDB468F9-AACD-4EAF-9EBB-8DB0972963EB} {55ABF7B2-A718-4A0D-9B90-479D9D549E7D} = {DDB468F9-AACD-4EAF-9EBB-8DB0972963EB} + {A0B1C2D3-E4F5-6789-ABCD-100000000001} = {DDB468F9-AACD-4EAF-9EBB-8DB0972963EB} + {A0B1C2D3-E4F5-6789-ABCD-100000000002} = {DDB468F9-AACD-4EAF-9EBB-8DB0972963EB} + {A0B1C2D3-E4F5-6789-ABCD-100000000003} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A0B1C2D3-E4F5-6789-ABCD-100000000004} = {A0B1C2D3-E4F5-6789-ABCD-100000000003} + {A0B1C2D3-E4F5-6789-ABCD-100000000005} = {3C4D5E6F-7A8B-9C0D-1E2F-A3B4C5D6E7F8} EndGlobalSection EndGlobal diff --git a/src/AgencyLayer/AgencyLayer.csproj b/src/AgencyLayer/AgencyLayer.csproj index f80f94a3..0727cb8d 100644 --- a/src/AgencyLayer/AgencyLayer.csproj +++ b/src/AgencyLayer/AgencyLayer.csproj @@ -20,6 +20,7 @@ + @@ -53,6 +54,7 @@ + diff --git a/src/AgencyLayer/SelfHealing/Engines/RemediationPolicyDecisionEngine.cs b/src/AgencyLayer/SelfHealing/Engines/RemediationPolicyDecisionEngine.cs new file mode 100644 index 00000000..9093f9d3 --- /dev/null +++ b/src/AgencyLayer/SelfHealing/Engines/RemediationPolicyDecisionEngine.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Logging; + +using CognitiveMesh.AgencyLayer.SelfHealing.Ports; +using CognitiveMesh.FoundationLayer.PolicyStore.Models; +using CognitiveMesh.FoundationLayer.PolicyStore.Ports; + +namespace CognitiveMesh.AgencyLayer.SelfHealing.Engines; + +/// +/// Decision engine that resolves remediation actions by consulting the policy store. +/// +public sealed class RemediationPolicyDecisionEngine : IRemediationDecisionPort +{ + private readonly IRemediationPolicyPort _policyPort; + private readonly ILogger _logger; + + /// + /// Initialises a new instance of the class. + /// + /// The remediation policy port. + /// The logger instance. + /// Thrown when any argument is . + public RemediationPolicyDecisionEngine( + IRemediationPolicyPort policyPort, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(policyPort); + ArgumentNullException.ThrowIfNull(logger); + + _policyPort = policyPort; + _logger = logger; + } + + /// + public async Task<(RemediationAction AllowedActions, Dictionary RankingWeights)> GetAllowedActionsAsync( + string incidentCategory, + string severity, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(incidentCategory); + ArgumentException.ThrowIfNullOrWhiteSpace(severity); + + var policy = await _policyPort.GetPolicyAsync(incidentCategory, severity, ct).ConfigureAwait(false); + + _logger.LogInformation( + "Resolved policy {PolicyId} v{Version} for {Category}/{Severity} with actions {Actions}", + policy.Id, + policy.Version, + incidentCategory, + severity, + policy.AllowedActions); + + return (policy.AllowedActions, policy.RankingWeights); + } +} diff --git a/src/AgencyLayer/SelfHealing/Extensions/SelfHealingServiceExtensions.cs b/src/AgencyLayer/SelfHealing/Extensions/SelfHealingServiceExtensions.cs new file mode 100644 index 00000000..57944833 --- /dev/null +++ b/src/AgencyLayer/SelfHealing/Extensions/SelfHealingServiceExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; + +using CognitiveMesh.AgencyLayer.SelfHealing.Engines; +using CognitiveMesh.AgencyLayer.SelfHealing.Ports; + +namespace CognitiveMesh.AgencyLayer.SelfHealing.Extensions; + +/// +/// Extension methods for registering self-healing services with the dependency injection container. +/// +public static class SelfHealingServiceExtensions +{ + /// + /// Adds self-healing services, including the remediation policy decision engine, + /// to the specified . + /// + /// The service collection to configure. + /// The same for chaining. + public static IServiceCollection AddSelfHealingServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + + return services; + } +} diff --git a/src/AgencyLayer/SelfHealing/Ports/IRemediationDecisionPort.cs b/src/AgencyLayer/SelfHealing/Ports/IRemediationDecisionPort.cs new file mode 100644 index 00000000..41ee09e6 --- /dev/null +++ b/src/AgencyLayer/SelfHealing/Ports/IRemediationDecisionPort.cs @@ -0,0 +1,25 @@ +using CognitiveMesh.FoundationLayer.PolicyStore.Models; + +namespace CognitiveMesh.AgencyLayer.SelfHealing.Ports; + +/// +/// Defines the port for obtaining remediation decisions based on incident context. +/// +public interface IRemediationDecisionPort +{ + /// + /// Retrieves the allowed remediation actions and their ranking weights for the + /// specified incident category and severity. + /// + /// The category of the incident. + /// The severity level of the incident. + /// A cancellation token. + /// + /// A tuple containing the allowed flags and + /// a dictionary of ranking weights keyed by action name. + /// + Task<(RemediationAction AllowedActions, Dictionary RankingWeights)> GetAllowedActionsAsync( + string incidentCategory, + string severity, + CancellationToken ct = default); +} diff --git a/src/AgencyLayer/SelfHealing/SelfHealing.csproj b/src/AgencyLayer/SelfHealing/SelfHealing.csproj new file mode 100644 index 00000000..b1598366 --- /dev/null +++ b/src/AgencyLayer/SelfHealing/SelfHealing.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + diff --git a/src/FoundationLayer/FoundationLayer.csproj b/src/FoundationLayer/FoundationLayer.csproj index 676a6dcb..943d8ca7 100644 --- a/src/FoundationLayer/FoundationLayer.csproj +++ b/src/FoundationLayer/FoundationLayer.csproj @@ -26,6 +26,7 @@ + diff --git a/src/FoundationLayer/PolicyStore/Adapters/CosmosDbRemediationPolicyAdapter.cs b/src/FoundationLayer/PolicyStore/Adapters/CosmosDbRemediationPolicyAdapter.cs new file mode 100644 index 00000000..55a7db68 --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Adapters/CosmosDbRemediationPolicyAdapter.cs @@ -0,0 +1,306 @@ +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using CognitiveMesh.FoundationLayer.PolicyStore.Models; +using CognitiveMesh.FoundationLayer.PolicyStore.Options; +using CognitiveMesh.FoundationLayer.PolicyStore.Ports; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Adapters; + +/// +/// Cosmos DB implementation of that provides +/// policy retrieval, upsert, deletion, and version history with in-memory caching. +/// +public sealed class CosmosDbRemediationPolicyAdapter : IRemediationPolicyPort +{ + private const string PolicyCacheKey = "policies:all"; + + private readonly CosmosClient _cosmosClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly PolicyStoreOptions _options; + + /// + /// Initialises a new instance of the class. + /// + /// The policy store configuration options. + /// The in-memory cache instance. + /// The logger instance. + /// Thrown when any argument is . + public CosmosDbRemediationPolicyAdapter( + IOptions options, + IMemoryCache cache, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(cache); + ArgumentNullException.ThrowIfNull(logger); + + _options = options.Value; + _cache = cache; + _logger = logger; + _cosmosClient = new CosmosClient(_options.CosmosDbConnectionString); + } + + /// + public async Task GetPolicyAsync( + string incidentCategory, + string severity, + CancellationToken ct = default) + { + var cacheKey = $"policy:{incidentCategory}:{severity}"; + + if (_cache.TryGetValue(cacheKey, out RemediationPolicy? cached) && cached is not null) + { + _logger.LogDebug("Cache hit for policy {Category}/{Severity}", incidentCategory, severity); + return cached; + } + + try + { + var container = GetPolicyContainer(); + var query = new QueryDefinition( + "SELECT * FROM c WHERE c.IncidentCategory = @category AND c.Severity = @severity AND c.IsActive = true") + .WithParameter("@category", incidentCategory) + .WithParameter("@severity", severity); + + using var iterator = container.GetItemQueryIterator( + query, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(incidentCategory) }); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(ct).ConfigureAwait(false); + results.AddRange(response); + } + + var policy = results.OrderByDescending(p => p.Version).FirstOrDefault(); + + if (policy is null) + { + _logger.LogWarning( + "No policy found for {Category}/{Severity}; returning default", + incidentCategory, + severity); + return BuildDefaultPolicy(incidentCategory, severity); + } + + _cache.Set(cacheKey, policy, _options.CacheTtl); + return policy; + } + catch (CosmosException ex) + { + _logger.LogError(ex, "Cosmos DB error retrieving policy for {Category}/{Severity}", incidentCategory, severity); + return BuildDefaultPolicy(incidentCategory, severity); + } + } + + /// + public async Task> ListPoliciesAsync(CancellationToken ct = default) + { + if (_cache.TryGetValue(PolicyCacheKey, out IEnumerable? cached) && cached is not null) + { + _logger.LogDebug("Cache hit for all policies"); + return cached; + } + + try + { + var container = GetPolicyContainer(); + var query = new QueryDefinition("SELECT * FROM c WHERE c.IsActive = true"); + + using var iterator = container.GetItemQueryIterator(query); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(ct).ConfigureAwait(false); + results.AddRange(response); + } + + _cache.Set(PolicyCacheKey, results.AsEnumerable(), _options.CacheTtl); + return results; + } + catch (CosmosException ex) + { + _logger.LogError(ex, "Cosmos DB error listing policies"); + return []; + } + } + + /// + public async Task UpsertPolicyAsync( + RemediationPolicy policy, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(policy); + + var container = GetPolicyContainer(); + + // Deactivate any existing active version + var existingQuery = new QueryDefinition( + "SELECT * FROM c WHERE c.id = @id AND c.IsActive = true") + .WithParameter("@id", policy.Id.ToString()); + + using var iterator = container.GetItemQueryIterator( + existingQuery, + requestOptions: new QueryRequestOptions { PartitionKey = new PartitionKey(policy.IncidentCategory) }); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(ct).ConfigureAwait(false); + foreach (var existing in response) + { + existing.IsActive = false; + await container.UpsertItemAsync( + existing, + new PartitionKey(existing.IncidentCategory), + cancellationToken: ct).ConfigureAwait(false); + } + } + + // Increment version and persist the new policy + policy.Version += 1; + policy.UpdatedAt = DateTimeOffset.UtcNow; + + var result = await container.UpsertItemAsync( + policy, + new PartitionKey(policy.IncidentCategory), + cancellationToken: ct).ConfigureAwait(false); + + // Write audit entry + var auditEntry = new PolicyAuditEntry + { + Id = Guid.NewGuid(), + PolicyId = policy.Id, + Operation = "Upsert", + Version = policy.Version, + Actor = policy.CreatedBy, + Timestamp = DateTimeOffset.UtcNow, + IncidentCategory = policy.IncidentCategory + }; + + var auditContainer = GetAuditContainer(); + await auditContainer.UpsertItemAsync( + auditEntry, + new PartitionKey(auditEntry.IncidentCategory), + cancellationToken: ct).ConfigureAwait(false); + + InvalidateCache(policy.IncidentCategory, policy.Severity); + + _logger.LogInformation( + "Upserted policy {PolicyId} version {Version} for {Category}/{Severity}", + policy.Id, + policy.Version, + policy.IncidentCategory, + policy.Severity); + + return result.Resource; + } + + /// + public async Task DeletePolicyAsync(Guid id, CancellationToken ct = default) + { + var container = GetPolicyContainer(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", id.ToString()); + + using var iterator = container.GetItemQueryIterator(query); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(ct).ConfigureAwait(false); + foreach (var policy in response) + { + policy.IsActive = false; + await container.UpsertItemAsync( + policy, + new PartitionKey(policy.IncidentCategory), + cancellationToken: ct).ConfigureAwait(false); + + // Write audit entry + var auditEntry = new PolicyAuditEntry + { + Id = Guid.NewGuid(), + PolicyId = id, + Operation = "Delete", + Version = policy.Version, + Actor = "system", + Timestamp = DateTimeOffset.UtcNow, + IncidentCategory = policy.IncidentCategory + }; + + var auditContainer = GetAuditContainer(); + await auditContainer.UpsertItemAsync( + auditEntry, + new PartitionKey(auditEntry.IncidentCategory), + cancellationToken: ct).ConfigureAwait(false); + + InvalidateCache(policy.IncidentCategory, policy.Severity); + } + } + + _logger.LogInformation("Deleted (soft) policy {PolicyId}", id); + } + + /// + public async Task> GetPolicyHistoryAsync( + Guid id, + CancellationToken ct = default) + { + var container = GetPolicyContainer(); + + var query = new QueryDefinition("SELECT * FROM c WHERE c.id = @id") + .WithParameter("@id", id.ToString()); + + using var iterator = container.GetItemQueryIterator(query); + + var results = new List(); + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(ct).ConfigureAwait(false); + results.AddRange(response); + } + + return results.OrderByDescending(p => p.Version); + } + + private static RemediationPolicy BuildDefaultPolicy(string incidentCategory, string severity) => + new() + { + Id = Guid.NewGuid(), + Version = 1, + IsActive = true, + IncidentCategory = incidentCategory, + Severity = severity, + AllowedActions = RemediationAction.Retry | RemediationAction.Escalate, + RankingWeights = new Dictionary + { + ["Retry"] = 0.7, + ["Escalate"] = 0.3 + }, + MaxRetries = 3, + RepeatedFailureEscalationThreshold = 5, + HumanInLoopRequired = false, + ApproverRoles = [], + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + CreatedBy = "system" + }; + + private Container GetPolicyContainer() => + _cosmosClient.GetContainer(_options.DatabaseId, _options.PolicyContainerId); + + private Container GetAuditContainer() => + _cosmosClient.GetContainer(_options.DatabaseId, _options.AuditContainerId); + + private void InvalidateCache(string incidentCategory, string severity) + { + _cache.Remove($"policy:{incidentCategory}:{severity}"); + _cache.Remove(PolicyCacheKey); + } +} diff --git a/src/FoundationLayer/PolicyStore/Extensions/PolicyStoreServiceExtensions.cs b/src/FoundationLayer/PolicyStore/Extensions/PolicyStoreServiceExtensions.cs new file mode 100644 index 00000000..94ba913f --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Extensions/PolicyStoreServiceExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +using CognitiveMesh.FoundationLayer.PolicyStore.Adapters; +using CognitiveMesh.FoundationLayer.PolicyStore.Options; +using CognitiveMesh.FoundationLayer.PolicyStore.Ports; +using CognitiveMesh.FoundationLayer.PolicyStore.Seed; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Extensions; + +/// +/// Extension methods for registering PolicyStore services with the dependency injection container. +/// +public static class PolicyStoreServiceExtensions +{ + /// + /// Adds the policy store services to the specified , + /// including Cosmos DB–backed policy persistence, in-memory caching, and seed initialisation. + /// + /// The service collection to configure. + /// The application configuration used to bind . + /// The same for chaining. + public static IServiceCollection AddPolicyStore( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.Configure(configuration.GetSection(nameof(PolicyStoreOptions))); + services.AddMemoryCache(); + services.AddSingleton(); + services.AddTransient(); + + return services; + } +} diff --git a/src/FoundationLayer/PolicyStore/Models/PolicyAuditEntry.cs b/src/FoundationLayer/PolicyStore/Models/PolicyAuditEntry.cs new file mode 100644 index 00000000..8b4eeef1 --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Models/PolicyAuditEntry.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Models; + +/// +/// Represents an audit log entry that records a change made to a remediation policy. +/// +public sealed class PolicyAuditEntry +{ + /// Gets or sets the unique identifier of the audit entry. + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// Gets or sets the identifier of the policy that was changed. + public Guid PolicyId { get; set; } + + /// Gets or sets the type of operation performed (e.g., Create, Update, Delete). + public string Operation { get; set; } = string.Empty; + + /// Gets or sets the version of the policy after the operation. + public int Version { get; set; } + + /// Gets or sets the identity of the actor who performed the operation. + public string Actor { get; set; } = string.Empty; + + /// Gets or sets the timestamp when the operation occurred. + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + + /// Gets or sets optional details describing the operation. + public string? Details { get; set; } + + /// Gets or sets the incident category of the policy that was changed. + public string IncidentCategory { get; set; } = string.Empty; +} diff --git a/src/FoundationLayer/PolicyStore/Models/RemediationAction.cs b/src/FoundationLayer/PolicyStore/Models/RemediationAction.cs new file mode 100644 index 00000000..f7e23673 --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Models/RemediationAction.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Models; + +/// +/// Defines the set of remediation actions that can be applied to an incident. +/// This enum supports bitwise combinations of its member values. +/// +[Flags] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RemediationAction +{ + /// No remediation action. + None = 0, + + /// Retry the failed operation. + Retry = 1, + + /// Roll back the changes that caused the incident. + Rollback = 2, + + /// Reassign the task to a different agent or handler. + Reassign = 4, + + /// Restart the affected service or process. + Restart = 8, + + /// Escalate the incident to a human operator or higher-level handler. + Escalate = 16 +} diff --git a/src/FoundationLayer/PolicyStore/Models/RemediationPolicy.cs b/src/FoundationLayer/PolicyStore/Models/RemediationPolicy.cs new file mode 100644 index 00000000..fcf5476a --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Models/RemediationPolicy.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Models; + +/// +/// Represents a remediation policy that defines how incidents of a given category +/// and severity should be handled by the self-healing subsystem. +/// +public sealed class RemediationPolicy +{ + /// Gets or sets the unique identifier of the policy. + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// Gets or sets the version number of the policy. + public int Version { get; set; } = 1; + + /// Gets or sets a value indicating whether the policy is currently active. + public bool IsActive { get; set; } = true; + + /// Gets or sets the incident category this policy applies to (e.g., infrastructure, application). + public string IncidentCategory { get; set; } = string.Empty; + + /// Gets or sets the severity level this policy applies to (e.g., low, medium, high, critical). + public string Severity { get; set; } = string.Empty; + + /// Gets or sets the set of remediation actions permitted by this policy. + public RemediationAction AllowedActions { get; set; } + + /// Gets or sets the ranking weights used to prioritise remediation actions. + public Dictionary RankingWeights { get; set; } = new(); + + /// Gets or sets the maximum number of retries before escalation. + public int MaxRetries { get; set; } = 3; + + /// Gets or sets the number of repeated failures required to trigger escalation. + public int RepeatedFailureEscalationThreshold { get; set; } = 5; + + /// Gets or sets a value indicating whether a human must approve remediation actions. + public bool HumanInLoopRequired { get; set; } + + /// Gets or sets the roles permitted to approve remediation actions when human-in-the-loop is required. + public List ApproverRoles { get; set; } = []; + + /// Gets or sets the timestamp when the policy was created. + public DateTimeOffset CreatedAt { get; set; } + + /// Gets or sets the timestamp when the policy was last updated. + public DateTimeOffset UpdatedAt { get; set; } + + /// Gets or sets the identity of the user or system that created the policy. + public string CreatedBy { get; set; } = string.Empty; +} diff --git a/src/FoundationLayer/PolicyStore/Options/PolicyStoreOptions.cs b/src/FoundationLayer/PolicyStore/Options/PolicyStoreOptions.cs new file mode 100644 index 00000000..a48f7b65 --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Options/PolicyStoreOptions.cs @@ -0,0 +1,22 @@ +namespace CognitiveMesh.FoundationLayer.PolicyStore.Options; + +/// +/// Configuration options for the Cosmos DB–backed policy store. +/// +public sealed class PolicyStoreOptions +{ + /// Gets or sets the Cosmos DB connection string. + public string CosmosDbConnectionString { get; set; } = string.Empty; + + /// Gets or sets the Cosmos DB database identifier. + public string DatabaseId { get; set; } = "CognitiveMesh"; + + /// Gets or sets the container identifier for remediation policies. + public string PolicyContainerId { get; set; } = "RemediationPolicies"; + + /// Gets or sets the container identifier for policy audit log entries. + public string AuditContainerId { get; set; } = "PolicyAuditLog"; + + /// Gets or sets the duration that cached policy data remains valid. + public TimeSpan CacheTtl { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/src/FoundationLayer/PolicyStore/PolicyStore.csproj b/src/FoundationLayer/PolicyStore/PolicyStore.csproj new file mode 100644 index 00000000..dc2c063f --- /dev/null +++ b/src/FoundationLayer/PolicyStore/PolicyStore.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + diff --git a/src/FoundationLayer/PolicyStore/Ports/IRemediationPolicyPort.cs b/src/FoundationLayer/PolicyStore/Ports/IRemediationPolicyPort.cs new file mode 100644 index 00000000..30d841d5 --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Ports/IRemediationPolicyPort.cs @@ -0,0 +1,49 @@ +using CognitiveMesh.FoundationLayer.PolicyStore.Models; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Ports; + +/// +/// Defines the port for retrieving and managing remediation policies. +/// +public interface IRemediationPolicyPort +{ + /// + /// Retrieves the active remediation policy for the specified incident category and severity. + /// + /// The incident category to look up. + /// The severity level to look up. + /// A cancellation token. + /// The matching . + Task GetPolicyAsync(string incidentCategory, string severity, CancellationToken ct = default); + + /// + /// Lists all active remediation policies. + /// + /// A cancellation token. + /// A collection of active instances. + Task> ListPoliciesAsync(CancellationToken ct = default); + + /// + /// Creates or updates a remediation policy. If a policy with the same identifier already exists, + /// it is deactivated and a new version is created. + /// + /// The policy to upsert. + /// A cancellation token. + /// The persisted . + Task UpsertPolicyAsync(RemediationPolicy policy, CancellationToken ct = default); + + /// + /// Soft-deletes a remediation policy by marking all versions as inactive. + /// + /// The unique identifier of the policy to delete. + /// A cancellation token. + Task DeletePolicyAsync(Guid id, CancellationToken ct = default); + + /// + /// Retrieves the full version history of a remediation policy, ordered by descending version number. + /// + /// The unique identifier of the policy. + /// A cancellation token. + /// A collection of versions. + Task> GetPolicyHistoryAsync(Guid id, CancellationToken ct = default); +} diff --git a/src/FoundationLayer/PolicyStore/Seed/DefaultPolicySeed.cs b/src/FoundationLayer/PolicyStore/Seed/DefaultPolicySeed.cs new file mode 100644 index 00000000..29c8779b --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Seed/DefaultPolicySeed.cs @@ -0,0 +1,175 @@ +using CognitiveMesh.FoundationLayer.PolicyStore.Models; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Seed; + +/// +/// Provides the default set of remediation policies used to seed the policy store. +/// +public static class DefaultPolicySeed +{ + /// + /// Returns the full set of default remediation policies covering all + /// incident categories (infrastructure, application, data, security) at + /// every severity level (low, medium, high, critical). + /// + /// A read-only list of 16 default instances. + public static IReadOnlyList GetDefaultPolicies() + { + var now = DateTimeOffset.UtcNow; + + return + [ + // ── Infrastructure ────────────────────────────────────────── + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "infrastructure", Severity = "low", + AllowedActions = RemediationAction.Retry, + RankingWeights = new Dictionary { ["Retry"] = 1.0 }, + MaxRetries = 5, RepeatedFailureEscalationThreshold = 10, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "infrastructure", Severity = "medium", + AllowedActions = RemediationAction.Retry | RemediationAction.Restart, + RankingWeights = new Dictionary { ["Retry"] = 0.6, ["Restart"] = 0.4 }, + MaxRetries = 3, RepeatedFailureEscalationThreshold = 7, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "infrastructure", Severity = "high", + AllowedActions = RemediationAction.Retry | RemediationAction.Restart | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.3, ["Restart"] = 0.4, ["Escalate"] = 0.3 }, + MaxRetries = 2, RepeatedFailureEscalationThreshold = 5, + HumanInLoopRequired = true, ApproverRoles = ["SRE", "PlatformAdmin"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "infrastructure", Severity = "critical", + AllowedActions = RemediationAction.Retry | RemediationAction.Rollback | RemediationAction.Restart | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.1, ["Rollback"] = 0.3, ["Restart"] = 0.2, ["Escalate"] = 0.4 }, + MaxRetries = 1, RepeatedFailureEscalationThreshold = 3, + HumanInLoopRequired = true, ApproverRoles = ["SRE", "PlatformAdmin", "IncidentCommander"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + + // ── Application ──────────────────────────────────────────── + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "application", Severity = "low", + AllowedActions = RemediationAction.Retry, + RankingWeights = new Dictionary { ["Retry"] = 1.0 }, + MaxRetries = 5, RepeatedFailureEscalationThreshold = 10, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "application", Severity = "medium", + AllowedActions = RemediationAction.Retry | RemediationAction.Reassign, + RankingWeights = new Dictionary { ["Retry"] = 0.6, ["Reassign"] = 0.4 }, + MaxRetries = 3, RepeatedFailureEscalationThreshold = 7, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "application", Severity = "high", + AllowedActions = RemediationAction.Retry | RemediationAction.Rollback | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.3, ["Rollback"] = 0.4, ["Escalate"] = 0.3 }, + MaxRetries = 2, RepeatedFailureEscalationThreshold = 5, + HumanInLoopRequired = true, ApproverRoles = ["AppOwner", "TechLead"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "application", Severity = "critical", + AllowedActions = RemediationAction.Retry | RemediationAction.Rollback | RemediationAction.Reassign | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.1, ["Rollback"] = 0.3, ["Reassign"] = 0.2, ["Escalate"] = 0.4 }, + MaxRetries = 1, RepeatedFailureEscalationThreshold = 3, + HumanInLoopRequired = true, ApproverRoles = ["AppOwner", "TechLead", "IncidentCommander"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + + // ── Data ─────────────────────────────────────────────────── + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "data", Severity = "low", + AllowedActions = RemediationAction.Retry, + RankingWeights = new Dictionary { ["Retry"] = 1.0 }, + MaxRetries = 5, RepeatedFailureEscalationThreshold = 10, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "data", Severity = "medium", + AllowedActions = RemediationAction.Retry | RemediationAction.Rollback, + RankingWeights = new Dictionary { ["Retry"] = 0.5, ["Rollback"] = 0.5 }, + MaxRetries = 3, RepeatedFailureEscalationThreshold = 7, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "data", Severity = "high", + AllowedActions = RemediationAction.Retry | RemediationAction.Rollback | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.2, ["Rollback"] = 0.5, ["Escalate"] = 0.3 }, + MaxRetries = 2, RepeatedFailureEscalationThreshold = 5, + HumanInLoopRequired = true, ApproverRoles = ["DataEngineer", "DBA"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "data", Severity = "critical", + AllowedActions = RemediationAction.Rollback | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Rollback"] = 0.5, ["Escalate"] = 0.5 }, + MaxRetries = 1, RepeatedFailureEscalationThreshold = 2, + HumanInLoopRequired = true, ApproverRoles = ["DataEngineer", "DBA", "IncidentCommander"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + + // ── Security ─────────────────────────────────────────────── + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "security", Severity = "low", + AllowedActions = RemediationAction.Retry | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.7, ["Escalate"] = 0.3 }, + MaxRetries = 3, RepeatedFailureEscalationThreshold = 5, + HumanInLoopRequired = false, ApproverRoles = [], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "security", Severity = "medium", + AllowedActions = RemediationAction.Retry | RemediationAction.Reassign | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.3, ["Reassign"] = 0.3, ["Escalate"] = 0.4 }, + MaxRetries = 2, RepeatedFailureEscalationThreshold = 4, + HumanInLoopRequired = true, ApproverRoles = ["SecurityAnalyst"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "security", Severity = "high", + AllowedActions = RemediationAction.Rollback | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Rollback"] = 0.4, ["Escalate"] = 0.6 }, + MaxRetries = 1, RepeatedFailureEscalationThreshold = 3, + HumanInLoopRequired = true, ApproverRoles = ["SecurityAnalyst", "CISO"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + }, + new RemediationPolicy + { + Id = Guid.NewGuid(), IncidentCategory = "security", Severity = "critical", + AllowedActions = RemediationAction.Rollback | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Rollback"] = 0.3, ["Escalate"] = 0.7 }, + MaxRetries = 0, RepeatedFailureEscalationThreshold = 1, + HumanInLoopRequired = true, ApproverRoles = ["SecurityAnalyst", "CISO", "IncidentCommander"], + CreatedAt = now, UpdatedAt = now, CreatedBy = "system" + } + ]; + } +} diff --git a/src/FoundationLayer/PolicyStore/Seed/PolicyStoreInitializer.cs b/src/FoundationLayer/PolicyStore/Seed/PolicyStoreInitializer.cs new file mode 100644 index 00000000..27e56770 --- /dev/null +++ b/src/FoundationLayer/PolicyStore/Seed/PolicyStoreInitializer.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Logging; + +using CognitiveMesh.FoundationLayer.PolicyStore.Ports; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Seed; + +/// +/// Seeds the policy store with default remediation policies when the store is empty. +/// +public sealed class PolicyStoreInitializer +{ + private readonly IRemediationPolicyPort _policyPort; + private readonly ILogger _logger; + + /// + /// Initialises a new instance of the class. + /// + /// The remediation policy port used for persistence. + /// The logger instance. + /// Thrown when any argument is . + public PolicyStoreInitializer( + IRemediationPolicyPort policyPort, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(policyPort); + ArgumentNullException.ThrowIfNull(logger); + + _policyPort = policyPort; + _logger = logger; + } + + /// + /// Seeds the policy store with default policies if no policies currently exist. + /// + /// A cancellation token. + public async Task InitialiseAsync(CancellationToken ct = default) + { + var existing = await _policyPort.ListPoliciesAsync(ct).ConfigureAwait(false); + + if (existing.Any()) + { + _logger.LogInformation("Policy store already contains {Count} policies; skipping seed", existing.Count()); + return; + } + + _logger.LogInformation("Policy store is empty; seeding default policies"); + + var defaults = DefaultPolicySeed.GetDefaultPolicies(); + foreach (var policy in defaults) + { + await _policyPort.UpsertPolicyAsync(policy, ct).ConfigureAwait(false); + } + + _logger.LogInformation("Seeded {Count} default remediation policies", defaults.Count); + } +} diff --git a/tests/AgencyLayer/SelfHealing/RemediationPolicyDecisionEngineTests.cs b/tests/AgencyLayer/SelfHealing/RemediationPolicyDecisionEngineTests.cs new file mode 100644 index 00000000..c5312f35 --- /dev/null +++ b/tests/AgencyLayer/SelfHealing/RemediationPolicyDecisionEngineTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using CognitiveMesh.AgencyLayer.SelfHealing.Engines; +using CognitiveMesh.FoundationLayer.PolicyStore.Models; +using CognitiveMesh.FoundationLayer.PolicyStore.Ports; + +namespace CognitiveMesh.AgencyLayer.SelfHealing.Tests; + +public sealed class RemediationPolicyDecisionEngineTests +{ + private readonly Mock _policyPortMock = new(); + private RemediationPolicyDecisionEngine CreateSut() => + new(_policyPortMock.Object, NullLogger.Instance); + + [Fact] + public async Task GetAllowedActionsAsync_ValidPolicy_ReturnsActionsAndWeights() + { + var policy = new RemediationPolicy + { + IncidentCategory = "infrastructure", Severity = "high", + AllowedActions = RemediationAction.Retry | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.7, ["Escalate"] = 0.3 } + }; + _policyPortMock.Setup(p => p.GetPolicyAsync("infrastructure", "high", It.IsAny())).ReturnsAsync(policy); + var (actions, weights) = await CreateSut().GetAllowedActionsAsync("infrastructure", "high"); + actions.Should().HaveFlag(RemediationAction.Retry); + actions.Should().HaveFlag(RemediationAction.Escalate); + weights["Retry"].Should().Be(0.7); + weights["Escalate"].Should().Be(0.3); + } + + [Fact] + public async Task GetAllowedActionsAsync_PolicyPortFallback_ReturnsPermissiveActions() + { + var fallback = new RemediationPolicy + { + IncidentCategory = "application", Severity = "medium", + AllowedActions = RemediationAction.Retry | RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Retry"] = 0.7, ["Escalate"] = 0.3 } + }; + _policyPortMock.Setup(p => p.GetPolicyAsync("application", "medium", It.IsAny())).ReturnsAsync(fallback); + var (actions, _) = await CreateSut().GetAllowedActionsAsync("application", "medium"); + actions.Should().NotBe(RemediationAction.None); + } + + [Fact] + public async Task GetAllowedActionsAsync_SecurityCritical_ContainsEscalate() + { + var policy = new RemediationPolicy + { + IncidentCategory = "security", Severity = "critical", + AllowedActions = RemediationAction.Escalate, + RankingWeights = new Dictionary { ["Escalate"] = 1.0 } + }; + _policyPortMock.Setup(p => p.GetPolicyAsync("security", "critical", It.IsAny())).ReturnsAsync(policy); + var (actions, weights) = await CreateSut().GetAllowedActionsAsync("security", "critical"); + actions.Should().HaveFlag(RemediationAction.Escalate); + actions.Should().NotHaveFlag(RemediationAction.Retry); + weights["Escalate"].Should().Be(1.0); + } + + [Fact] + public async Task GetAllowedActionsAsync_NullCategory_ThrowsArgumentException() + { + var act = async () => await CreateSut().GetAllowedActionsAsync(null!, "high"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAllowedActionsAsync_EmptySeverity_ThrowsArgumentException() + { + var act = async () => await CreateSut().GetAllowedActionsAsync("infrastructure", ""); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAllowedActionsAsync_CallsDelegatesPortExactlyOnce() + { + var policy = new RemediationPolicy + { + IncidentCategory = "data", Severity = "low", + AllowedActions = RemediationAction.Retry, + RankingWeights = new Dictionary { ["Retry"] = 0.6 } + }; + _policyPortMock.Setup(p => p.GetPolicyAsync("data", "low", It.IsAny())).ReturnsAsync(policy); + await CreateSut().GetAllowedActionsAsync("data", "low"); + _policyPortMock.Verify(p => p.GetPolicyAsync("data", "low", It.IsAny()), Times.Once); + } +} diff --git a/tests/AgencyLayer/SelfHealing/SelfHealing.Tests.csproj b/tests/AgencyLayer/SelfHealing/SelfHealing.Tests.csproj new file mode 100644 index 00000000..6f7f4dd9 --- /dev/null +++ b/tests/AgencyLayer/SelfHealing/SelfHealing.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + false + false + $(NoWarn);CS1591 + + + + + + + + + + + + + + + + + diff --git a/tests/FoundationLayer/PolicyStore/PolicyStore.Tests.csproj b/tests/FoundationLayer/PolicyStore/PolicyStore.Tests.csproj new file mode 100644 index 00000000..4f186fc9 --- /dev/null +++ b/tests/FoundationLayer/PolicyStore/PolicyStore.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + false + false + $(NoWarn);CS1591 + + + + + + + + + + + + + + + + + diff --git a/tests/FoundationLayer/PolicyStore/RemediationPolicyAdapterTests.cs b/tests/FoundationLayer/PolicyStore/RemediationPolicyAdapterTests.cs new file mode 100644 index 00000000..5368dbf4 --- /dev/null +++ b/tests/FoundationLayer/PolicyStore/RemediationPolicyAdapterTests.cs @@ -0,0 +1,171 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using CognitiveMesh.FoundationLayer.PolicyStore.Models; +using CognitiveMesh.FoundationLayer.PolicyStore.Ports; +using CognitiveMesh.FoundationLayer.PolicyStore.Seed; + +namespace CognitiveMesh.FoundationLayer.PolicyStore.Tests; + +public sealed class RemediationPolicyAdapterTests +{ + private sealed class InMemoryPolicyStore : IRemediationPolicyPort + { + private readonly Dictionary _active = new(); + private readonly List _all = new(); + + public Task GetPolicyAsync(string incidentCategory, string severity, CancellationToken ct = default) + { + var key = Key(incidentCategory, severity); + return _active.TryGetValue(key, out var p) + ? Task.FromResult(p) + : Task.FromResult(BuildFallback(incidentCategory, severity)); + } + + public Task> ListPoliciesAsync(CancellationToken ct = default) => + Task.FromResult>(_active.Values.ToList()); + + public Task UpsertPolicyAsync(RemediationPolicy policy, CancellationToken ct = default) + { + var key = Key(policy.IncidentCategory, policy.Severity); + if (_active.TryGetValue(key, out var existing)) + { + var archived = new RemediationPolicy + { + Id = existing.Id, Version = existing.Version, IsActive = false, + IncidentCategory = existing.IncidentCategory, Severity = existing.Severity, + AllowedActions = existing.AllowedActions, RankingWeights = existing.RankingWeights, + MaxRetries = existing.MaxRetries, + RepeatedFailureEscalationThreshold = existing.RepeatedFailureEscalationThreshold, + HumanInLoopRequired = existing.HumanInLoopRequired, + ApproverRoles = existing.ApproverRoles, + CreatedAt = existing.CreatedAt, UpdatedAt = existing.UpdatedAt, + CreatedBy = existing.CreatedBy + }; + _all.Add(archived); + policy.Version = existing.Version + 1; + policy.Id = Guid.NewGuid(); + } + policy.IsActive = true; + _active[key] = policy; + _all.Add(policy); + return Task.FromResult(policy); + } + + public Task DeletePolicyAsync(Guid id, CancellationToken ct = default) + { + var toDelete = _active.Values.FirstOrDefault(p => p.Id == id); + if (toDelete is not null) + { + toDelete.IsActive = false; + _active.Remove(Key(toDelete.IncidentCategory, toDelete.Severity)); + } + return Task.CompletedTask; + } + + public Task> GetPolicyHistoryAsync(Guid id, CancellationToken ct = default) => + Task.FromResult>( + _all.Where(p => p.Id == id).OrderByDescending(p => p.Version)); + + private static string Key(string cat, string sev) => $"{cat}:{sev}"; + + private static RemediationPolicy BuildFallback(string cat, string sev) => + new() + { + IncidentCategory = cat, Severity = sev, + AllowedActions = RemediationAction.Retry | RemediationAction.Escalate, + CreatedBy = "fallback" + }; + } + + [Fact] + public async Task UpsertPolicyAsync_NewPolicy_SetsVersionToOne() + { + var store = new InMemoryPolicyStore(); + var result = await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "infrastructure", Severity = "low", AllowedActions = RemediationAction.Retry }); + result.Version.Should().Be(1); + result.IsActive.Should().BeTrue(); + } + + [Fact] + public async Task UpsertPolicyAsync_ExistingPolicy_IncrementsVersion() + { + var store = new InMemoryPolicyStore(); + await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "application", Severity = "medium", AllowedActions = RemediationAction.Retry }); + var result = await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "application", Severity = "medium", AllowedActions = RemediationAction.Retry | RemediationAction.Rollback }); + result.Version.Should().Be(2); + } + + [Fact] + public async Task UpsertPolicyAsync_ExistingPolicy_DeactivatesPreviousVersion() + { + var store = new InMemoryPolicyStore(); + await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "data", Severity = "high", AllowedActions = RemediationAction.Escalate }); + await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "data", Severity = "high", AllowedActions = RemediationAction.Escalate | RemediationAction.Retry }); + var active = await store.GetPolicyAsync("data", "high"); + active.IsActive.Should().BeTrue(); + active.Version.Should().Be(2); + } + + [Fact] + public async Task GetPolicyAsync_NonExistentPolicy_ReturnsFallbackPolicy() + { + var store = new InMemoryPolicyStore(); + var result = await store.GetPolicyAsync("unknown-category", "extreme"); + result.Should().NotBeNull(); + result.AllowedActions.Should().NotBe(RemediationAction.None); + result.CreatedBy.Should().Be("fallback"); + } + + [Fact] + public async Task DeletePolicyAsync_ExistingPolicy_RemovesFromActiveList() + { + var store = new InMemoryPolicyStore(); + var saved = await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "security", Severity = "critical", AllowedActions = RemediationAction.Escalate }); + await store.DeletePolicyAsync(saved.Id); + var active = await store.ListPoliciesAsync(); + active.Should().NotContain(p => p.Id == saved.Id && p.IsActive); + } + + [Fact] + public async Task ListPoliciesAsync_AfterUpsert_ReturnsAllActivePolicies() + { + var store = new InMemoryPolicyStore(); + await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "infrastructure", Severity = "low", AllowedActions = RemediationAction.Retry }); + await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "application", Severity = "medium", AllowedActions = RemediationAction.Rollback }); + var list = await store.ListPoliciesAsync(); + list.Should().HaveCount(2); + list.Should().OnlyContain(p => p.IsActive); + } + + [Fact] + public async Task PolicyStoreInitializer_EmptyStore_SeedsDefaultPolicies() + { + var store = new InMemoryPolicyStore(); + var initializer = new PolicyStoreInitializer(store, NullLogger.Instance); + await initializer.InitialiseAsync(); + var all = await store.ListPoliciesAsync(); + all.Should().HaveCount(DefaultPolicySeed.GetDefaultPolicies().Count); + } + + [Fact] + public async Task PolicyStoreInitializer_NonEmptyStore_DoesNotDuplicateSeed() + { + var store = new InMemoryPolicyStore(); + await store.UpsertPolicyAsync(new RemediationPolicy + { IncidentCategory = "infrastructure", Severity = "low", AllowedActions = RemediationAction.Retry }); + var initializer = new PolicyStoreInitializer(store, NullLogger.Instance); + await initializer.InitialiseAsync(); + await initializer.InitialiseAsync(); + var all = await store.ListPoliciesAsync(); + all.Should().HaveCount(1, "store already had data; seed was skipped"); + } +}