Implement Exception Effect Registry and Evaluation Service

- Added IExceptionEffectRegistry interface and its implementation ExceptionEffectRegistry to manage exception effects based on type and reason.
- Created ExceptionAwareEvaluationService for evaluating policies with automatic exception loading from the repository.
- Developed unit tests for ExceptionAdapter and ExceptionEffectRegistry to ensure correct behavior and mappings of exceptions and effects.
- Enhanced exception loading logic to filter expired and non-active exceptions, and to respect maximum exceptions limit.
- Implemented caching mechanism in ExceptionAdapter to optimize repeated exception loading.
This commit is contained in:
StellaOps Bot
2025-12-21 08:29:51 +02:00
parent b9c288782b
commit ba2f015184
37 changed files with 2825 additions and 1240 deletions

View File

@@ -0,0 +1,302 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Engine.Adapters;
/// <summary>
/// Options for exception adapter configuration.
/// </summary>
public sealed class ExceptionAdapterOptions
{
/// <summary>
/// Cache TTL for loaded exceptions. Default: 60 seconds.
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Maximum number of exceptions to load per tenant. Default: 10000.
/// </summary>
public int MaxExceptionsPerTenant { get; set; } = 10000;
/// <summary>
/// Whether to enable caching. Default: true.
/// </summary>
public bool EnableCaching { get; set; } = true;
}
/// <summary>
/// Interface for adapting persisted exception objects to policy evaluation context.
/// </summary>
internal interface IExceptionAdapter
{
/// <summary>
/// Loads active exceptions for a tenant and converts them to PolicyEvaluationExceptions.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="asOf">Point in time for expiry filtering (typically now).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Policy evaluation exceptions ready for use in evaluation context.</returns>
Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cached exceptions for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
void InvalidateCache(Guid tenantId);
/// <summary>
/// Invalidates all cached exceptions.
/// </summary>
void InvalidateAllCaches();
}
/// <summary>
/// Adapts persisted ExceptionObject entities to PolicyEvaluationExceptions for policy evaluation.
/// Includes caching layer for performance optimization.
/// </summary>
internal sealed class ExceptionAdapter : IExceptionAdapter
{
private readonly IExceptionRepository _repository;
private readonly IExceptionEffectRegistry _effectRegistry;
private readonly IMemoryCache _cache;
private readonly ExceptionAdapterOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionAdapter> _logger;
private static readonly string CacheKeyPrefix = "exception_adapter:";
public ExceptionAdapter(
IExceptionRepository repository,
IExceptionEffectRegistry effectRegistry,
IMemoryCache cache,
IOptions<ExceptionAdapterOptions> options,
TimeProvider timeProvider,
ILogger<ExceptionAdapter> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_effectRegistry = effectRegistry ?? throw new ArgumentNullException(nameof(effectRegistry));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? new ExceptionAdapterOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<PolicyEvaluationExceptions> LoadExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(tenantId);
if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out PolicyEvaluationExceptions? cached) && cached is not null)
{
_logger.LogDebug("Cache hit for tenant {TenantId} exceptions", tenantId);
return cached;
}
_logger.LogDebug("Loading exceptions from repository for tenant {TenantId}", tenantId);
// Load active exceptions from repository
var exceptions = await LoadActiveExceptionsAsync(tenantId, asOf, cancellationToken);
// Convert to evaluation context format
var result = ConvertToEvaluationExceptions(exceptions, asOf);
// Cache the result
if (_options.EnableCaching)
{
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.CacheTtl,
Size = 1
};
_cache.Set(cacheKey, result, cacheOptions);
}
_logger.LogDebug(
"Loaded {Count} active exceptions for tenant {TenantId}",
result.Instances.Length,
tenantId);
return result;
}
/// <inheritdoc />
public void InvalidateCache(Guid tenantId)
{
var cacheKey = BuildCacheKey(tenantId);
_cache.Remove(cacheKey);
_logger.LogDebug("Invalidated exception cache for tenant {TenantId}", tenantId);
}
/// <inheritdoc />
public void InvalidateAllCaches()
{
// IMemoryCache doesn't support enumeration, so we can't clear all entries.
// In practice, callers should invalidate specific tenants or use a distributed cache
// with proper invalidation patterns.
_logger.LogWarning("InvalidateAllCaches called but IMemoryCache doesn't support enumeration. Consider using tenant-specific invalidation.");
}
private async Task<IReadOnlyList<ExceptionObject>> LoadActiveExceptionsAsync(
Guid tenantId,
DateTimeOffset asOf,
CancellationToken cancellationToken)
{
// Create scope filter for active exceptions
var scope = new ExceptionScope
{
TenantId = tenantId
};
// Query repository for active exceptions not expired as of the given time
var candidates = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
// Filter to only active status and not expired
return candidates
.Where(ex => ex.Status == ExceptionStatus.Active)
.Where(ex => ex.ExpiresAt > asOf)
.Take(_options.MaxExceptionsPerTenant)
.ToList();
}
private PolicyEvaluationExceptions ConvertToEvaluationExceptions(
IReadOnlyList<ExceptionObject> exceptions,
DateTimeOffset asOf)
{
if (exceptions.Count == 0)
{
return PolicyEvaluationExceptions.Empty;
}
var effectsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyExceptionEffect>(StringComparer.OrdinalIgnoreCase);
var instancesBuilder = ImmutableArray.CreateBuilder<PolicyEvaluationExceptionInstance>(exceptions.Count);
foreach (var exception in exceptions)
{
// Get or create effect for this exception type/reason
var effect = _effectRegistry.GetEffect(exception.Type, exception.ReasonCode);
var effectId = effect.Id;
// Add effect to dictionary (de-duplicate by ID)
if (!effectsBuilder.ContainsKey(effectId))
{
effectsBuilder.Add(effectId, effect);
}
// Create scope from exception scope
var scope = ConvertScope(exception.Scope);
// Create instance
var instance = new PolicyEvaluationExceptionInstance(
Id: exception.ExceptionId,
EffectId: effectId,
Scope: scope,
CreatedAt: exception.CreatedAt,
Metadata: BuildMetadata(exception));
instancesBuilder.Add(instance);
}
return new PolicyEvaluationExceptions(
Effects: effectsBuilder.ToImmutable(),
Instances: instancesBuilder.ToImmutable());
}
private static PolicyEvaluationExceptionScope ConvertScope(ExceptionScope scope)
{
// Map exception scope to evaluation scope
// Policy rule IDs go to RuleNames
// Vulnerability IDs go to Sources (advisory source matching)
// PURL patterns go to Tags (for component matching)
var ruleNames = !string.IsNullOrEmpty(scope.PolicyRuleId)
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.PolicyRuleId)
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
var sources = !string.IsNullOrEmpty(scope.VulnerabilityId)
? ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, scope.VulnerabilityId)
: ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
var tags = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(scope.PurlPattern))
{
// Use PURL pattern as a tag for component-based matching
tags = tags.Add($"purl:{scope.PurlPattern}");
}
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
{
tags = tags.Add($"digest:{scope.ArtifactDigest}");
}
// Environments are stored as tags with env: prefix
foreach (var env in scope.Environments)
{
tags = tags.Add($"env:{env}");
}
// Severities are not directly mapped from ExceptionScope
// They would come from effect configuration
var severities = ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
return new PolicyEvaluationExceptionScope(
RuleNames: ruleNames,
Severities: severities,
Sources: sources,
Tags: tags);
}
private static ImmutableDictionary<string, string> BuildMetadata(ExceptionObject exception)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
builder["exception.type"] = exception.Type.ToString();
builder["exception.reason"] = exception.ReasonCode.ToString();
builder["exception.owner"] = exception.OwnerId;
builder["exception.requester"] = exception.RequesterId;
builder["exception.rationale"] = exception.Rationale;
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O");
if (exception.ApproverIds.Length > 0)
{
builder["exception.approvers"] = string.Join(",", exception.ApproverIds);
}
if (!string.IsNullOrEmpty(exception.TicketRef))
{
builder["exception.ticketRef"] = exception.TicketRef;
}
if (exception.EvidenceRefs.Length > 0)
{
builder["exception.evidenceRefs"] = string.Join(",", exception.EvidenceRefs);
}
if (exception.CompensatingControls.Length > 0)
{
builder["exception.compensatingControls"] = string.Join(",", exception.CompensatingControls);
}
// Copy custom metadata
foreach (var pair in exception.Metadata)
{
if (!builder.ContainsKey(pair.Key))
{
builder[$"meta.{pair.Key}"] = pair.Value;
}
}
return builder.ToImmutable();
}
private static string BuildCacheKey(Guid tenantId) => $"{CacheKeyPrefix}{tenantId:N}";
}

View File

@@ -0,0 +1,226 @@
using System.Collections.Frozen;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Engine.Adapters;
/// <summary>
/// Interface for looking up exception effects based on type and reason.
/// </summary>
public interface IExceptionEffectRegistry
{
/// <summary>
/// Gets the policy exception effect for a given exception type and reason.
/// </summary>
/// <param name="type">Exception type.</param>
/// <param name="reason">Exception reason code.</param>
/// <returns>The corresponding policy exception effect.</returns>
PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason);
/// <summary>
/// Gets all registered effects.
/// </summary>
IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects();
/// <summary>
/// Gets effect by ID.
/// </summary>
/// <param name="effectId">Effect identifier.</param>
/// <returns>Effect if found, null otherwise.</returns>
PolicyExceptionEffect? GetEffectById(string effectId);
}
/// <summary>
/// Registry mapping exception type/reason combinations to policy exception effects.
/// </summary>
/// <remarks>
/// Effect mappings follow the auditable exception principles:
/// - Suppress: Finding is suppressed from reports and gates
/// - Defer: Finding is deferred (tracked but not blocking)
/// - Downgrade: Finding severity is reduced
/// - RequireControl: Finding requires compensating control verification
/// </remarks>
public sealed class ExceptionEffectRegistry : IExceptionEffectRegistry
{
private readonly FrozenDictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> _effectMap;
private readonly FrozenDictionary<string, PolicyExceptionEffect> _effectsById;
private readonly PolicyExceptionEffect _defaultEffect;
public ExceptionEffectRegistry()
{
var effects = BuildDefaultEffects();
_effectMap = effects.ToFrozenDictionary();
_effectsById = effects.Values
.DistinctBy(e => e.Id)
.ToFrozenDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
_defaultEffect = CreateDefaultEffect();
}
/// <inheritdoc />
public PolicyExceptionEffect GetEffect(ExceptionType type, ExceptionReason reason)
{
return _effectMap.TryGetValue((type, reason), out var effect)
? effect
: _defaultEffect;
}
/// <inheritdoc />
public IReadOnlyCollection<PolicyExceptionEffect> GetAllEffects()
{
return _effectsById.Values;
}
/// <inheritdoc />
public PolicyExceptionEffect? GetEffectById(string effectId)
{
return _effectsById.TryGetValue(effectId, out var effect) ? effect : null;
}
private static Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect> BuildDefaultEffects()
{
// Define all effect templates
var suppress = new PolicyExceptionEffect(
Id: "suppress",
Name: "Suppress Finding",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: 365,
Description: "Suppresses the finding from reports and policy gates.");
var defer = new PolicyExceptionEffect(
Id: "defer",
Name: "Defer Finding",
Effect: PolicyExceptionEffectType.Defer,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "deferred-review",
MaxDurationDays: 90,
Description: "Defers the finding for later review without blocking.");
var requireControl = new PolicyExceptionEffect(
Id: "require-control",
Name: "Require Compensating Control",
Effect: PolicyExceptionEffectType.RequireControl,
DowngradeSeverity: null,
RequiredControlId: "compensating-control-verification",
RoutingTemplate: "control-verification",
MaxDurationDays: 180,
Description: "Requires verification of compensating controls before allowing.");
var downgradeToLow = new PolicyExceptionEffect(
Id: "downgrade-low",
Name: "Downgrade to Low",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Low,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: 365,
Description: "Downgrades finding severity to Low.");
var downgradeToMedium = new PolicyExceptionEffect(
Id: "downgrade-medium",
Name: "Downgrade to Medium",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Medium,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: 365,
Description: "Downgrades finding severity to Medium.");
var deferVendor = new PolicyExceptionEffect(
Id: "defer-vendor",
Name: "Awaiting Vendor Fix",
Effect: PolicyExceptionEffectType.Defer,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "vendor-tracking",
MaxDurationDays: 180,
Description: "Defers pending vendor patch release.");
var suppressDeprecation = new PolicyExceptionEffect(
Id: "suppress-deprecation",
Name: "Deprecation Waiver",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "deprecation-tracking",
MaxDurationDays: 90,
Description: "Temporary waiver during component deprecation.");
var suppressLicense = new PolicyExceptionEffect(
Id: "suppress-license",
Name: "License Waiver",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "legal-review",
MaxDurationDays: 365,
Description: "License compliance waiver after legal review.");
// Build the mapping
return new Dictionary<(ExceptionType, ExceptionReason), PolicyExceptionEffect>
{
// Vulnerability exceptions
[(ExceptionType.Vulnerability, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Vulnerability, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Vulnerability, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Vulnerability, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
[(ExceptionType.Vulnerability, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Vulnerability, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Vulnerability, ExceptionReason.Other)] = defer,
// Policy exceptions
[(ExceptionType.Policy, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Policy, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Policy, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Policy, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Policy, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Policy, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Policy, ExceptionReason.DeprecationInProgress)] = defer,
[(ExceptionType.Policy, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Policy, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Policy, ExceptionReason.Other)] = defer,
// Unknown findings exceptions
[(ExceptionType.Unknown, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Unknown, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Unknown, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Unknown, ExceptionReason.DeprecationInProgress)] = defer,
[(ExceptionType.Unknown, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Unknown, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Unknown, ExceptionReason.Other)] = defer,
// Component exceptions
[(ExceptionType.Component, ExceptionReason.FalsePositive)] = suppress,
[(ExceptionType.Component, ExceptionReason.AcceptedRisk)] = suppress,
[(ExceptionType.Component, ExceptionReason.CompensatingControl)] = requireControl,
[(ExceptionType.Component, ExceptionReason.TestOnly)] = suppress,
[(ExceptionType.Component, ExceptionReason.VendorNotAffected)] = suppress,
[(ExceptionType.Component, ExceptionReason.ScheduledFix)] = defer,
[(ExceptionType.Component, ExceptionReason.DeprecationInProgress)] = suppressDeprecation,
[(ExceptionType.Component, ExceptionReason.RuntimeMitigation)] = downgradeToLow,
[(ExceptionType.Component, ExceptionReason.NetworkIsolation)] = downgradeToMedium,
[(ExceptionType.Component, ExceptionReason.Other)] = suppressLicense,
};
}
private static PolicyExceptionEffect CreateDefaultEffect()
{
return new PolicyExceptionEffect(
Id: "defer-default",
Name: "Default Deferral",
Effect: PolicyExceptionEffectType.Defer,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "manual-review",
MaxDurationDays: 30,
Description: "Default effect for unmapped exception type/reason combinations.");
}
}

View File

@@ -290,4 +290,32 @@ public static class PolicyEngineServiceCollectionExtensions
services.Configure(configure);
return services.AddPolicyEngine();
}
}
/// <summary>
/// Adds exception integration services for automatic exception loading during policy evaluation.
/// Requires IExceptionRepository to be registered.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Optional configuration for exception adapter options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddPolicyExceptionIntegration(
this IServiceCollection services,
Action<Adapters.ExceptionAdapterOptions>? configure = null)
{
if (configure is not null)
{
services.Configure(configure);
}
// Register the effect registry (singleton, stateless)
services.TryAddSingleton<Adapters.IExceptionEffectRegistry, Adapters.ExceptionEffectRegistry>();
// Register the exception adapter (singleton, uses IMemoryCache for caching)
services.TryAddSingleton<Adapters.IExceptionAdapter, Adapters.ExceptionAdapter>();
// Register the exception-aware evaluation service
services.TryAddSingleton<Services.IExceptionAwareEvaluationService, Services.ExceptionAwareEvaluationService>();
return services;
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Engine.Domain;

View File

@@ -0,0 +1,320 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Adapters;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Request for exception-aware policy evaluation.
/// Extends the base RuntimeEvaluationRequest with exception loading options.
/// </summary>
internal sealed record ExceptionAwareEvaluationRequest
{
/// <summary>
/// Base evaluation request.
/// </summary>
public required RuntimeEvaluationRequest BaseRequest { get; init; }
/// <summary>
/// Whether to automatically load exceptions from the repository.
/// If false, uses exceptions from BaseRequest.Exceptions (default behavior).
/// If true, loads exceptions for the tenant and merges with any provided exceptions.
/// </summary>
public bool LoadExceptionsFromRepository { get; init; } = true;
/// <summary>
/// Tenant ID for loading exceptions. Required if LoadExceptionsFromRepository is true.
/// Falls back to parsing from TenantId in BaseRequest if not provided.
/// </summary>
public Guid? ExceptionTenantId { get; init; }
}
/// <summary>
/// Response from exception-aware policy evaluation.
/// </summary>
internal sealed record ExceptionAwareEvaluationResponse
{
/// <summary>
/// The underlying evaluation response.
/// </summary>
public required RuntimeEvaluationResponse Response { get; init; }
/// <summary>
/// Number of exceptions that were loaded from the repository.
/// </summary>
public int LoadedExceptionCount { get; init; }
/// <summary>
/// Whether exceptions were loaded from the repository.
/// </summary>
public bool ExceptionsLoadedFromRepository { get; init; }
/// <summary>
/// Duration of exception loading in milliseconds.
/// </summary>
public long ExceptionLoadDurationMs { get; init; }
}
/// <summary>
/// Interface for exception-aware policy evaluation.
/// Automatically loads exceptions from the repository before evaluation.
/// </summary>
internal interface IExceptionAwareEvaluationService
{
/// <summary>
/// Evaluates a policy with automatic exception loading.
/// </summary>
Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
ExceptionAwareEvaluationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates multiple requests in batch with automatic exception loading.
/// Exceptions are loaded once per tenant for efficiency.
/// </summary>
Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Exception-aware policy evaluation service.
/// Wraps PolicyRuntimeEvaluationService and automatically loads exceptions from the repository.
/// </summary>
internal sealed class ExceptionAwareEvaluationService : IExceptionAwareEvaluationService
{
private readonly PolicyRuntimeEvaluationService _evaluator;
private readonly IExceptionAdapter _exceptionAdapter;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionAwareEvaluationService> _logger;
public ExceptionAwareEvaluationService(
PolicyRuntimeEvaluationService evaluator,
IExceptionAdapter exceptionAdapter,
TimeProvider timeProvider,
ILogger<ExceptionAwareEvaluationService> logger)
{
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
_exceptionAdapter = exceptionAdapter ?? throw new ArgumentNullException(nameof(exceptionAdapter));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<ExceptionAwareEvaluationResponse> EvaluateAsync(
ExceptionAwareEvaluationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.BaseRequest);
var loadStartTimestamp = _timeProvider.GetTimestamp();
var loadedCount = 0;
var exceptionsLoaded = false;
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
if (request.LoadExceptionsFromRepository)
{
var tenantId = ResolveTenantId(request);
if (tenantId.HasValue)
{
var asOf = request.BaseRequest.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
var loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
tenantId.Value,
asOf,
cancellationToken);
// Merge loaded exceptions with any exceptions in the original request
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
loadedCount = loadedExceptions.Instances.Length;
exceptionsLoaded = true;
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
_logger.LogDebug(
"Loaded {Count} exceptions for tenant {TenantId} in evaluation request",
loadedCount,
tenantId.Value);
}
else
{
_logger.LogWarning(
"LoadExceptionsFromRepository is true but no tenant ID available. " +
"Falling back to exceptions in request.");
}
}
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
// Delegate to core evaluator
var response = await _evaluator.EvaluateAsync(enrichedRequest, cancellationToken);
// Record telemetry for exception loading
if (exceptionsLoaded && loadedCount > 0)
{
PolicyEngineTelemetry.RecordExceptionLoaded(
enrichedRequest.TenantId,
loadedCount,
loadDuration / 1000.0);
}
return new ExceptionAwareEvaluationResponse
{
Response = response,
LoadedExceptionCount = loadedCount,
ExceptionsLoadedFromRepository = exceptionsLoaded,
ExceptionLoadDurationMs = loadDuration
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionAwareEvaluationResponse>> EvaluateBatchAsync(
IReadOnlyList<ExceptionAwareEvaluationRequest> requests,
CancellationToken cancellationToken = default)
{
if (requests.Count == 0)
{
return Array.Empty<ExceptionAwareEvaluationResponse>();
}
var loadStartTimestamp = _timeProvider.GetTimestamp();
// Group requests by tenant to load exceptions efficiently
var tenantExceptionsCache = new Dictionary<Guid, PolicyEvaluationExceptions>();
var enrichedRequests = new List<(ExceptionAwareEvaluationRequest Original, RuntimeEvaluationRequest Enriched, int LoadedCount)>();
var asOf = _timeProvider.GetUtcNow();
foreach (var request in requests)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.BaseRequest);
var loadedCount = 0;
RuntimeEvaluationRequest enrichedRequest = request.BaseRequest;
if (request.LoadExceptionsFromRepository)
{
var tenantId = ResolveTenantId(request);
if (tenantId.HasValue)
{
// Check cache first
if (!tenantExceptionsCache.TryGetValue(tenantId.Value, out var loadedExceptions))
{
var requestAsOf = request.BaseRequest.EvaluationTimestamp ?? asOf;
loadedExceptions = await _exceptionAdapter.LoadExceptionsAsync(
tenantId.Value,
requestAsOf,
cancellationToken);
tenantExceptionsCache[tenantId.Value] = loadedExceptions;
}
var mergedExceptions = MergeExceptions(request.BaseRequest.Exceptions, loadedExceptions);
loadedCount = loadedExceptions.Instances.Length;
enrichedRequest = request.BaseRequest with { Exceptions = mergedExceptions };
}
}
enrichedRequests.Add((request, enrichedRequest, loadedCount));
}
var loadDuration = GetElapsedMilliseconds(loadStartTimestamp);
// Evaluate all enriched requests
var baseRequests = enrichedRequests.Select(e => e.Enriched).ToList();
var responses = await _evaluator.EvaluateBatchAsync(baseRequests, cancellationToken);
// Build responses
var results = new List<ExceptionAwareEvaluationResponse>(requests.Count);
for (int i = 0; i < enrichedRequests.Count; i++)
{
var (original, _, loadedCount) = enrichedRequests[i];
results.Add(new ExceptionAwareEvaluationResponse
{
Response = responses[i],
LoadedExceptionCount = loadedCount,
ExceptionsLoadedFromRepository = original.LoadExceptionsFromRepository,
ExceptionLoadDurationMs = loadDuration / requests.Count // Amortized
});
}
_logger.LogDebug(
"Batch evaluation with exception loading: {RequestCount} requests, {TenantCount} tenants, {TotalLoaded} total exceptions loaded",
requests.Count,
tenantExceptionsCache.Count,
tenantExceptionsCache.Values.Sum(e => e.Instances.Length));
return results;
}
private Guid? ResolveTenantId(ExceptionAwareEvaluationRequest request)
{
// First try explicit exception tenant ID
if (request.ExceptionTenantId.HasValue)
{
return request.ExceptionTenantId.Value;
}
// Then try parsing from TenantId string in base request
if (Guid.TryParse(request.BaseRequest.TenantId, out var parsedTenantId))
{
return parsedTenantId;
}
return null;
}
private static PolicyEvaluationExceptions MergeExceptions(
PolicyEvaluationExceptions original,
PolicyEvaluationExceptions loaded)
{
if (original.IsEmpty)
{
return loaded;
}
if (loaded.IsEmpty)
{
return original;
}
// Merge effects (loaded takes precedence for same ID)
var mergedEffects = original.Effects.ToBuilder();
foreach (var effect in loaded.Effects)
{
mergedEffects[effect.Key] = effect.Value;
}
// Merge instances (combine and de-duplicate by ID)
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mergedInstances = new List<PolicyEvaluationExceptionInstance>();
// Add original instances first (these take precedence as they were explicitly provided)
foreach (var instance in original.Instances)
{
if (seenIds.Add(instance.Id))
{
mergedInstances.Add(instance);
}
}
// Add loaded instances that don't conflict
foreach (var instance in loaded.Instances)
{
if (seenIds.Add(instance.Id))
{
mergedInstances.Add(instance);
}
}
return new PolicyEvaluationExceptions(
mergedEffects.ToImmutable(),
mergedInstances.ToImmutableArray());
}
private long GetElapsedMilliseconds(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
return (long)elapsed.TotalMilliseconds;
}
}

View File

@@ -469,6 +469,20 @@ public static class PolicyEngineTelemetry
unit: "events",
description: "Lifecycle events for exceptions (activated, expired, revoked).");
// Counter: policy_exception_loaded_total{tenant}
private static readonly Counter<long> ExceptionLoadedCounter =
Meter.CreateCounter<long>(
"policy_exception_loaded_total",
unit: "exceptions",
description: "Total exceptions loaded from repository for evaluation.");
// Histogram: policy_exception_load_latency_seconds{tenant}
private static readonly Histogram<double> ExceptionLoadLatencyHistogram =
Meter.CreateHistogram<double>(
"policy_exception_load_latency_seconds",
unit: "s",
description: "Latency of loading exceptions from repository.");
/// <summary>
/// Counter for exception cache operations.
/// </summary>
@@ -879,6 +893,23 @@ public static class PolicyEngineTelemetry
ExceptionLifecycleCounter.Add(1, tags);
}
/// <summary>
/// Records exceptions loaded from repository for evaluation.
/// </summary>
/// <param name="tenant">Tenant identifier.</param>
/// <param name="count">Number of exceptions loaded.</param>
/// <param name="latencySeconds">Time taken to load exceptions in seconds.</param>
public static void RecordExceptionLoaded(string tenant, int count, double latencySeconds)
{
var tags = new TagList
{
{ "tenant", NormalizeTenant(tenant) },
};
ExceptionLoadedCounter.Add(count, tags);
ExceptionLoadLatencyHistogram.Record(latencySeconds, tags);
}
#region Golden Signals - Recording Methods
/// <summary>