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");
+ }
+}