using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Caching;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.PolicyDsl;
namespace StellaOps.Policy.Engine.Services;
///
/// Request for runtime policy evaluation over linkset/SBOM data.
///
internal sealed record RuntimeEvaluationRequest(
string PackId,
int Version,
string TenantId,
string SubjectPurl,
string AdvisoryId,
PolicyEvaluationSeverity Severity,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions,
PolicyEvaluationReachability Reachability,
string? EntropyLayerSummary,
string? EntropyReport,
bool? ProvenanceAttested,
DateTimeOffset? EvaluationTimestamp = null,
bool BypassCache = false);
///
/// Response from runtime policy evaluation.
///
internal sealed record RuntimeEvaluationResponse(
string PackId,
int Version,
string PolicyDigest,
string Status,
string? Severity,
string? RuleName,
int? Priority,
ImmutableDictionary Annotations,
ImmutableArray Warnings,
PolicyExceptionApplication? AppliedException,
string CorrelationId,
bool Cached,
CacheSource CacheSource,
long EvaluationDurationMs);
///
/// Runtime evaluator executing compiled policy plans over advisory/VEX linksets and SBOM asset metadata
/// with deterministic caching (Redis) and fallback path.
///
internal sealed class PolicyRuntimeEvaluationService
{
private readonly IPolicyPackRepository _repository;
private readonly IPolicyEvaluationCache _cache;
private readonly PolicyEvaluator _evaluator;
private readonly ReachabilityFacts.ReachabilityFactsJoiningService? _reachabilityFacts;
private readonly Signals.Entropy.EntropyPenaltyCalculator _entropy;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
private static readonly JsonSerializerOptions ContextSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public PolicyRuntimeEvaluationService(
IPolicyPackRepository repository,
IPolicyEvaluationCache cache,
PolicyEvaluator evaluator,
ReachabilityFacts.ReachabilityFactsJoiningService? reachabilityFacts,
Signals.Entropy.EntropyPenaltyCalculator entropy,
TimeProvider timeProvider,
ILogger logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
_reachabilityFacts = reachabilityFacts;
_entropy = entropy ?? throw new ArgumentNullException(nameof(entropy));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
/// Evaluates a policy against the provided context with deterministic caching.
///
public async Task EvaluateAsync(
RuntimeEvaluationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var startTimestamp = _timeProvider.GetTimestamp();
var evaluationTimestamp = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
var effectiveRequest = _reachabilityFacts is null
? request
: await EnrichReachabilityAsync(request, cancellationToken).ConfigureAwait(false);
using var activity = PolicyEngineTelemetry.StartEvaluateActivity(
effectiveRequest.TenantId, effectiveRequest.PackId, runId: null);
activity?.SetTag("policy.version", effectiveRequest.Version);
activity?.SetTag("subject.purl", effectiveRequest.SubjectPurl);
activity?.SetTag("advisory.id", effectiveRequest.AdvisoryId);
// Load the compiled policy bundle
var bundle = await _repository.GetBundleAsync(effectiveRequest.PackId, effectiveRequest.Version, cancellationToken)
.ConfigureAwait(false);
if (bundle is null)
{
PolicyEngineTelemetry.RecordError("evaluation", effectiveRequest.TenantId);
PolicyEngineTelemetry.RecordEvaluationFailure(effectiveRequest.TenantId, effectiveRequest.PackId, "bundle_not_found");
activity?.SetStatus(ActivityStatusCode.Error, "Bundle not found");
throw new InvalidOperationException(
$"Policy bundle not found for pack '{effectiveRequest.PackId}' version {effectiveRequest.Version}.");
}
// Compute deterministic cache key
var subjectDigest = ComputeSubjectDigest(effectiveRequest.TenantId, effectiveRequest.SubjectPurl, effectiveRequest.AdvisoryId);
var contextDigest = ComputeContextDigest(effectiveRequest);
var cacheKey = PolicyEvaluationCacheKey.Create(bundle.Digest, subjectDigest, contextDigest);
// Try cache lookup unless bypassed
if (!effectiveRequest.BypassCache)
{
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.CacheHit && cacheResult.Entry is not null)
{
var duration = GetElapsedMilliseconds(startTimestamp);
var durationSeconds = duration / 1000.0;
PolicyEngineTelemetry.RecordEvaluationLatency(durationSeconds, request.TenantId, request.PackId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, request.PackId, "cached");
activity?.SetTag("cache.hit", true);
activity?.SetTag("cache.source", cacheResult.Source.ToString());
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Cache hit for evaluation {PackId}@{Version} subject {Subject} from {Source}",
effectiveRequest.PackId, effectiveRequest.Version, effectiveRequest.SubjectPurl, cacheResult.Source);
return CreateResponseFromCache(
effectiveRequest, bundle.Digest, cacheResult.Entry, cacheResult.Source, duration);
}
}
activity?.SetTag("cache.hit", false);
// Cache miss - perform evaluation
var document = bundle.CompiledDocument;
if (document is null)
{
PolicyEngineTelemetry.RecordError("evaluation", request.TenantId);
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, request.PackId, "document_not_found");
activity?.SetStatus(ActivityStatusCode.Error, "Document not found");
throw new InvalidOperationException(
$"Compiled policy document not found for pack '{request.PackId}' version {request.Version}.");
}
var entropy = ComputeEntropy(effectiveRequest);
var context = new PolicyEvaluationContext(
effectiveRequest.Severity,
new PolicyEvaluationEnvironment(ImmutableDictionary.Empty),
effectiveRequest.Advisory,
effectiveRequest.Vex,
effectiveRequest.Sbom,
effectiveRequest.Exceptions,
effectiveRequest.Reachability,
entropy,
evaluationTimestamp);
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
var result = _evaluator.Evaluate(evalRequest);
var correlationId = ComputeCorrelationId(bundle.Digest, subjectDigest, contextDigest);
var expiresAt = evaluationTimestamp.AddMinutes(30);
// Store in cache
var cacheEntry = new PolicyEvaluationCacheEntry(
result.Status,
result.Severity,
result.RuleName,
result.Priority,
result.Annotations,
result.Warnings,
result.AppliedException?.ExceptionId,
correlationId,
evaluationTimestamp,
expiresAt);
await _cache.SetAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false);
var evalDuration = GetElapsedMilliseconds(startTimestamp);
var evalDurationSeconds = evalDuration / 1000.0;
// Record metrics
PolicyEngineTelemetry.RecordEvaluationLatency(evalDurationSeconds, effectiveRequest.TenantId, effectiveRequest.PackId);
PolicyEngineTelemetry.RecordEvaluation(effectiveRequest.TenantId, effectiveRequest.PackId, "full");
if (!string.IsNullOrEmpty(result.RuleName))
{
PolicyEngineTelemetry.RecordRuleFired(effectiveRequest.PackId, result.RuleName);
}
if (result.AppliedException is not null)
{
PolicyEngineTelemetry.RecordExceptionApplication(effectiveRequest.TenantId, result.AppliedException.EffectType.ToString());
PolicyEngineTelemetry.RecordExceptionApplicationLatency(evalDurationSeconds, effectiveRequest.TenantId, result.AppliedException.EffectType.ToString());
_logger.LogInformation(
"Applied exception {ExceptionId} (effect {EffectType}) for tenant {TenantId} pack {PackId}@{Version} aoc {CompilationId}",
result.AppliedException.ExceptionId,
result.AppliedException.EffectType,
effectiveRequest.TenantId,
effectiveRequest.PackId,
effectiveRequest.Version,
bundle.AocMetadata?.CompilationId ?? "none");
}
activity?.SetTag("evaluation.status", result.Status);
activity?.SetTag("evaluation.rule", result.RuleName ?? "none");
activity?.SetTag("evaluation.duration_ms", evalDuration);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Evaluated {PackId}@{Version} subject {Subject} in {Duration}ms - {Status}",
effectiveRequest.PackId, effectiveRequest.Version, effectiveRequest.SubjectPurl, evalDuration, result.Status);
return new RuntimeEvaluationResponse(
effectiveRequest.PackId,
effectiveRequest.Version,
bundle.Digest,
result.Status,
result.Severity,
result.RuleName,
result.Priority,
result.Annotations,
result.Warnings,
result.AppliedException,
correlationId,
Cached: false,
CacheSource: CacheSource.None,
EvaluationDurationMs: evalDuration);
}
///
/// Evaluates multiple subjects in batch with caching.
///
public async Task> EvaluateBatchAsync(
IReadOnlyList requests,
CancellationToken cancellationToken)
{
if (requests.Count == 0)
{
return Array.Empty();
}
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("policy.evaluate_batch", ActivityKind.Internal);
activity?.SetTag("batch.size", requests.Count);
var batchStartTimestamp = _timeProvider.GetTimestamp();
var results = new List(requests.Count);
var cacheHits = 0;
var cacheMisses = 0;
var hydratedRequests = _reachabilityFacts is null
? requests
: await EnrichReachabilityBatchAsync(requests, cancellationToken).ConfigureAwait(false);
// Group by pack/version for bundle loading efficiency
var groups = hydratedRequests.GroupBy(r => (r.PackId, r.Version));
foreach (var group in groups)
{
var (packId, version) = group.Key;
var bundle = await _repository.GetBundleAsync(packId, version, cancellationToken)
.ConfigureAwait(false);
if (bundle is null)
{
foreach (var request in group)
{
PolicyEngineTelemetry.RecordEvaluationFailure(request.TenantId, packId, "bundle_not_found");
_logger.LogWarning(
"Policy bundle not found for pack '{PackId}' version {Version}, skipping evaluation",
packId, version);
}
continue;
}
var document = bundle.CompiledDocument;
if (document is null)
{
PolicyEngineTelemetry.RecordEvaluationFailure("default", packId, "document_not_found");
_logger.LogWarning(
"Compiled policy document not found for pack '{PackId}' version {Version}",
packId, version);
continue;
}
// Build cache keys for batch lookup
var cacheKeys = new List<(RuntimeEvaluationRequest Request, PolicyEvaluationCacheKey Key)>();
foreach (var request in group)
{
var subjectDigest = ComputeSubjectDigest(request.TenantId, request.SubjectPurl, request.AdvisoryId);
var contextDigest = ComputeContextDigest(request);
var key = PolicyEvaluationCacheKey.Create(bundle.Digest, subjectDigest, contextDigest);
cacheKeys.Add((request, key));
}
// Batch cache lookup
var keyList = cacheKeys.Select(k => k.Key).ToList();
var cacheResults = await _cache.GetBatchAsync(keyList, cancellationToken).ConfigureAwait(false);
var toEvaluate = new List<(RuntimeEvaluationRequest Request, PolicyEvaluationCacheKey Key)>();
// Process cache hits
foreach (var (request, key) in cacheKeys)
{
if (!request.BypassCache && cacheResults.Found.TryGetValue(key, out var entry))
{
var response = CreateResponseFromCache(request, bundle.Digest, entry, CacheSource.InMemory, 0);
results.Add(response);
cacheHits++;
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, packId, "cached");
}
else
{
toEvaluate.Add((request, key));
}
}
// Evaluate cache misses
var entriesToCache = new Dictionary();
foreach (var (request, key) in toEvaluate)
{
var startTimestamp = _timeProvider.GetTimestamp();
var evaluationTimestamp = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
var entropy = ComputeEntropy(request);
var context = new PolicyEvaluationContext(
request.Severity,
new PolicyEvaluationEnvironment(ImmutableDictionary.Empty),
request.Advisory,
request.Vex,
request.Sbom,
request.Exceptions,
request.Reachability,
entropy,
evaluationTimestamp);
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
var result = _evaluator.Evaluate(evalRequest);
var correlationId = ComputeCorrelationId(bundle.Digest, key.SubjectDigest, key.ContextDigest);
var expiresAt = evaluationTimestamp.AddMinutes(30);
var duration = GetElapsedMilliseconds(startTimestamp);
var cacheEntry = new PolicyEvaluationCacheEntry(
result.Status,
result.Severity,
result.RuleName,
result.Priority,
result.Annotations,
result.Warnings,
result.AppliedException?.ExceptionId,
correlationId,
evaluationTimestamp,
expiresAt);
entriesToCache[key] = cacheEntry;
cacheMisses++;
// Record metrics for each evaluation
PolicyEngineTelemetry.RecordEvaluationLatency(duration / 1000.0, request.TenantId, packId);
PolicyEngineTelemetry.RecordEvaluation(request.TenantId, packId, "full");
if (!string.IsNullOrEmpty(result.RuleName))
{
PolicyEngineTelemetry.RecordRuleFired(packId, result.RuleName);
}
if (result.AppliedException is not null)
{
PolicyEngineTelemetry.RecordExceptionApplication(request.TenantId, result.AppliedException.EffectType.ToString());
PolicyEngineTelemetry.RecordExceptionApplicationLatency(duration / 1000.0, request.TenantId, result.AppliedException.EffectType.ToString());
_logger.LogInformation(
"Applied exception {ExceptionId} (effect {EffectType}) for tenant {TenantId} pack {PackId}@{Version} aoc {CompilationId}",
result.AppliedException.ExceptionId,
result.AppliedException.EffectType,
request.TenantId,
request.PackId,
request.Version,
bundle.AocMetadata?.CompilationId ?? "none");
}
results.Add(new RuntimeEvaluationResponse(
request.PackId,
request.Version,
bundle.Digest,
result.Status,
result.Severity,
result.RuleName,
result.Priority,
result.Annotations,
result.Warnings,
result.AppliedException,
correlationId,
Cached: false,
CacheSource: CacheSource.None,
EvaluationDurationMs: duration));
}
// Batch store cache entries
if (entriesToCache.Count > 0)
{
await _cache.SetBatchAsync(entriesToCache, cancellationToken).ConfigureAwait(false);
}
}
// Record batch-level metrics
var batchDuration = GetElapsedMilliseconds(batchStartTimestamp);
activity?.SetTag("batch.cache_hits", cacheHits);
activity?.SetTag("batch.cache_misses", cacheMisses);
activity?.SetTag("batch.duration_ms", batchDuration);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogDebug(
"Batch evaluation completed: {Total} subjects, {CacheHits} cache hits, {CacheMisses} evaluated in {Duration}ms",
requests.Count, cacheHits, cacheMisses, batchDuration);
return results;
}
private static RuntimeEvaluationResponse CreateResponseFromCache(
RuntimeEvaluationRequest request,
string policyDigest,
PolicyEvaluationCacheEntry entry,
CacheSource source,
long durationMs)
{
PolicyExceptionApplication? appliedException = null;
if (entry.ExceptionId is not null)
{
// Reconstruct minimal exception application from cache
appliedException = new PolicyExceptionApplication(
entry.ExceptionId,
EffectId: "cached",
EffectType: PolicyExceptionEffectType.Suppress,
OriginalStatus: entry.Status,
OriginalSeverity: entry.Severity,
AppliedStatus: entry.Status,
AppliedSeverity: entry.Severity,
Metadata: ImmutableDictionary.Empty);
}
return new RuntimeEvaluationResponse(
request.PackId,
request.Version,
policyDigest,
entry.Status,
entry.Severity,
entry.RuleName,
entry.Priority,
entry.Annotations,
entry.Warnings,
appliedException,
entry.CorrelationId,
Cached: true,
CacheSource: source,
EvaluationDurationMs: durationMs);
}
private static string ComputeSubjectDigest(string tenantId, string subjectPurl, string advisoryId)
{
var input = $"{tenantId}|{subjectPurl}|{advisoryId}";
Span hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
return Convert.ToHexStringLower(hash);
}
private static string ComputeContextDigest(RuntimeEvaluationRequest request)
{
// Create deterministic context representation
var contextData = new
{
severity = request.Severity.Normalized,
severityScore = request.Severity.Score,
advisorySource = request.Advisory.Source,
vexCount = request.Vex.Statements.Length,
vexStatements = request.Vex.Statements.Select(s => $"{s.Status}:{s.Justification}").OrderBy(s => s).ToArray(),
sbomTags = request.Sbom.Tags.OrderBy(t => t).ToArray(),
exceptionCount = request.Exceptions.Instances.Length,
reachability = new
{
state = request.Reachability.State,
confidence = request.Reachability.Confidence,
score = request.Reachability.Score,
hasRuntimeEvidence = request.Reachability.HasRuntimeEvidence,
source = request.Reachability.Source,
method = request.Reachability.Method
},
entropy = new
{
layerSummary = request.EntropyLayerSummary is null ? null : StableHash(request.EntropyLayerSummary),
entropyReport = request.EntropyReport is null ? null : StableHash(request.EntropyReport),
provenanceAttested = request.ProvenanceAttested ?? false
}
};
var json = JsonSerializer.Serialize(contextData, ContextSerializerOptions);
Span hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(json), hash);
return Convert.ToHexStringLower(hash);
}
private static string ComputeCorrelationId(string policyDigest, string subjectDigest, string contextDigest)
{
var input = $"{policyDigest}|{subjectDigest}|{contextDigest}";
Span hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
return Convert.ToHexString(hash);
}
private long GetElapsedMilliseconds(long startTimestamp)
{
var elapsed = _timeProvider.GetElapsedTime(startTimestamp);
return (long)elapsed.TotalMilliseconds;
}
private PolicyEvaluationEntropy ComputeEntropy(RuntimeEvaluationRequest request)
{
if (string.IsNullOrWhiteSpace(request.EntropyLayerSummary))
{
return PolicyEvaluationEntropy.Unknown;
}
try
{
var result = _entropy.ComputeFromJson(
request.EntropyLayerSummary!,
request.EntropyReport,
request.ProvenanceAttested ?? false);
return new PolicyEvaluationEntropy(
Penalty: result.Penalty,
ImageOpaqueRatio: result.ImageOpaqueRatio,
Blocked: result.Blocked,
Warned: result.Warned,
Capped: result.Capped,
TopFileOpaqueRatio: result.TopFiles.FirstOrDefault()?.OpaqueRatio);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to compute entropy penalty; defaulting to zero.");
return PolicyEvaluationEntropy.Unknown;
}
}
private static string StableHash(string input)
{
Span hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
return Convert.ToHexStringLower(hash);
}
private async Task EnrichReachabilityAsync(
RuntimeEvaluationRequest request,
CancellationToken cancellationToken)
{
if (_reachabilityFacts is null || !request.Reachability.IsUnknown)
{
return request;
}
var fact = await _reachabilityFacts
.GetFactAsync(request.TenantId, request.SubjectPurl, request.AdvisoryId, cancellationToken)
.ConfigureAwait(false);
if (fact is null)
{
return request;
}
var reachability = new PolicyEvaluationReachability(
State: fact.State.ToString().ToLowerInvariant(),
Confidence: fact.Confidence,
Score: fact.Score,
HasRuntimeEvidence: fact.HasRuntimeEvidence,
Source: fact.Source,
Method: fact.Method.ToString().ToLowerInvariant(),
EvidenceRef: fact.EvidenceRef ?? fact.EvidenceHash);
ReachabilityFacts.ReachabilityFactsTelemetry.RecordFactApplied(reachability.State);
return request with { Reachability = reachability };
}
private async Task> EnrichReachabilityBatchAsync(
IReadOnlyList requests,
CancellationToken cancellationToken)
{
if (_reachabilityFacts is null)
{
return requests;
}
var enriched = new List(requests.Count);
foreach (var tenantGroup in requests.GroupBy(r => r.TenantId, StringComparer.Ordinal))
{
var pending = tenantGroup
.Where(r => r.Reachability.IsUnknown)
.Select(r => new ReachabilityFacts.ReachabilityFactsRequest(r.SubjectPurl, r.AdvisoryId))
.Distinct()
.ToList();
ReachabilityFacts.ReachabilityFactsBatch? batch = null;
if (pending.Count > 0)
{
batch = await _reachabilityFacts
.GetFactsBatchAsync(tenantGroup.Key, pending, cancellationToken)
.ConfigureAwait(false);
}
var lookup = batch?.Found ?? new Dictionary();
foreach (var request in tenantGroup)
{
if (!request.Reachability.IsUnknown)
{
enriched.Add(request);
continue;
}
var key = new ReachabilityFacts.ReachabilityFactKey(request.TenantId, request.SubjectPurl, request.AdvisoryId);
if (lookup.TryGetValue(key, out var fact))
{
var reachability = new PolicyEvaluationReachability(
State: fact.State.ToString().ToLowerInvariant(),
Confidence: fact.Confidence,
Score: fact.Score,
HasRuntimeEvidence: fact.HasRuntimeEvidence,
Source: fact.Source,
Method: fact.Method.ToString().ToLowerInvariant(),
EvidenceRef: fact.EvidenceRef ?? fact.EvidenceHash);
ReachabilityFacts.ReachabilityFactsTelemetry.RecordFactApplied(reachability.State);
enriched.Add(request with { Reachability = reachability });
}
else
{
enriched.Add(request);
}
}
}
return enriched;
}
}