This commit is contained in:
StellaOps Bot
2025-12-13 02:22:15 +02:00
parent 564df71bfb
commit 999e26a48e
395 changed files with 25045 additions and 2224 deletions

View File

@@ -0,0 +1,428 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.EffectiveDecisionMap;
/// <summary>
/// Transport-agnostic effective decision map using StellaOps.Messaging abstractions.
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
/// </summary>
internal sealed class MessagingEffectiveDecisionMap : IEffectiveDecisionMap
{
private readonly IDistributedCache<string, EffectiveDecisionEntry> _entryCache;
private readonly ISortedIndex<string, string> _assetIndex;
private readonly IDistributedCache<string, long> _versionCache;
private readonly ILogger<MessagingEffectiveDecisionMap> _logger;
private readonly EffectiveDecisionMapOptions _options;
private readonly TimeProvider _timeProvider;
private const string EntryKeyPrefix = "edm:entry";
private const string IndexKeyPrefix = "edm:index";
private const string VersionKeyPrefix = "edm:version";
public MessagingEffectiveDecisionMap(
IDistributedCacheFactory cacheFactory,
ISortedIndexFactory sortedIndexFactory,
ILogger<MessagingEffectiveDecisionMap> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(cacheFactory);
ArgumentNullException.ThrowIfNull(sortedIndexFactory);
_entryCache = cacheFactory.Create<string, EffectiveDecisionEntry>(new CacheOptions { KeyPrefix = "edm:entries" });
_assetIndex = sortedIndexFactory.Create<string, string>("edm-asset-index");
_versionCache = cacheFactory.Create<string, long>(new CacheOptions { KeyPrefix = "edm:versions" });
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.EffectiveDecisionMap ?? new EffectiveDecisionMapOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task SetAsync(
string tenantId,
string snapshotId,
EffectiveDecisionEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
var now = _timeProvider.GetUtcNow();
var ttl = entry.ExpiresAt - now;
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
// Store entry with TTL
await _entryCache.SetAsync(entryKey, entry, cacheOptions, cancellationToken).ConfigureAwait(false);
// Add to sorted index by evaluated_at timestamp
var score = entry.EvaluatedAt.ToUnixTimeMilliseconds();
await _assetIndex.AddAsync(indexKey, entry.AssetId, score, cancellationToken).ConfigureAwait(false);
// Set TTL on index (slightly longer than entries)
await _assetIndex.SetExpirationAsync(indexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "set"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task SetBatchAsync(
string tenantId,
string snapshotId,
IEnumerable<EffectiveDecisionEntry> entries,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var indexKey = GetIndexKey(tenantId, snapshotId);
var count = 0;
var maxTtl = TimeSpan.Zero;
var indexElements = new List<ScoredElement<string>>();
foreach (var entry in entries)
{
var entryKey = GetEntryKey(tenantId, snapshotId, entry.AssetId);
var ttl = entry.ExpiresAt - now;
if (ttl <= TimeSpan.Zero)
{
ttl = TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
if (ttl > maxTtl) maxTtl = ttl;
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
await _entryCache.SetAsync(entryKey, entry, cacheOptions, cancellationToken).ConfigureAwait(false);
indexElements.Add(new ScoredElement<string>(entry.AssetId, entry.EvaluatedAt.ToUnixTimeMilliseconds()));
count++;
}
if (indexElements.Count > 0)
{
await _assetIndex.AddRangeAsync(indexKey, indexElements, cancellationToken).ConfigureAwait(false);
await _assetIndex.SetExpirationAsync(indexKey, maxTtl.Add(TimeSpan.FromMinutes(5)), cancellationToken).ConfigureAwait(false);
}
// Increment version after batch write
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count,
new KeyValuePair<string, object?>("operation", "set_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogDebug("Set {Count} effective decisions for snapshot {SnapshotId}", count, snapshotId);
}
public async Task<EffectiveDecisionEntry?> GetAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get"),
new KeyValuePair<string, object?>("tenant_id", tenantId),
new KeyValuePair<string, object?>("cache_hit", result.HasValue));
return result.HasValue ? result.Value : null;
}
public async Task<EffectiveDecisionQueryResult> GetBatchAsync(
string tenantId,
string snapshotId,
IReadOnlyList<string> assetIds,
CancellationToken cancellationToken = default)
{
var entries = new Dictionary<string, EffectiveDecisionEntry>();
var notFound = new List<string>();
foreach (var assetId in assetIds)
{
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false);
if (result.HasValue && result.Value is not null)
{
entries[assetId] = result.Value;
}
else
{
notFound.Add(assetId);
}
}
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(assetIds.Count,
new KeyValuePair<string, object?>("operation", "get_batch"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return new EffectiveDecisionQueryResult
{
Entries = entries,
NotFound = notFound,
MapVersion = version,
FromCache = true,
};
}
public async Task<IReadOnlyList<EffectiveDecisionEntry>> GetAllForSnapshotAsync(
string tenantId,
string snapshotId,
EffectiveDecisionFilter? filter = null,
CancellationToken cancellationToken = default)
{
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from index, ordered by score (evaluated_at) descending
var assetElements = await _assetIndex.GetByRankAsync(
indexKey, 0, -1, SortOrder.Descending, cancellationToken).ConfigureAwait(false);
if (assetElements.Count == 0)
{
return Array.Empty<EffectiveDecisionEntry>();
}
var entries = new List<EffectiveDecisionEntry>();
foreach (var element in assetElements)
{
var entryKey = GetEntryKey(tenantId, snapshotId, element.Element);
var result = await _entryCache.GetAsync(entryKey, cancellationToken).ConfigureAwait(false);
if (!result.HasValue || result.Value is null) continue;
var entry = result.Value;
// Apply filters
if (filter != null)
{
if (filter.Statuses?.Count > 0 &&
!filter.Statuses.Contains(entry.Status, StringComparer.OrdinalIgnoreCase))
{
continue;
}
if (filter.Severities?.Count > 0 &&
(entry.Severity is null || !filter.Severities.Contains(entry.Severity, StringComparer.OrdinalIgnoreCase)))
{
continue;
}
if (filter.HasException == true && entry.ExceptionId is null)
{
continue;
}
if (filter.HasException == false && entry.ExceptionId is not null)
{
continue;
}
if (filter.MinAdvisoryCount.HasValue && entry.AdvisoryCount < filter.MinAdvisoryCount)
{
continue;
}
if (filter.MinHighSeverityCount.HasValue && entry.HighSeverityCount < filter.MinHighSeverityCount)
{
continue;
}
}
entries.Add(entry);
// Apply limit (accounting for offset)
if (filter?.Limit > 0 && entries.Count >= filter.Limit + (filter?.Offset ?? 0))
{
break;
}
}
// Apply offset
if (filter?.Offset > 0)
{
entries = entries.Skip(filter.Offset).ToList();
}
// Apply final limit
if (filter?.Limit > 0)
{
entries = entries.Take(filter.Limit).ToList();
}
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "get_all"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
return entries;
}
public async Task<EffectiveDecisionSummary> GetSummaryAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var entries = await GetAllForSnapshotAsync(tenantId, snapshotId, null, cancellationToken)
.ConfigureAwait(false);
var statusCounts = entries
.GroupBy(e => e.Status, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var severityCounts = entries
.Where(e => e.Severity is not null)
.GroupBy(e => e.Severity!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var version = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
return new EffectiveDecisionSummary
{
SnapshotId = snapshotId,
TotalAssets = entries.Count,
StatusCounts = statusCounts,
SeverityCounts = severityCounts,
ExceptionCount = entries.Count(e => e.ExceptionId is not null),
MapVersion = version,
ComputedAt = _timeProvider.GetUtcNow(),
};
}
public async Task InvalidateAsync(
string tenantId,
string snapshotId,
string assetId,
CancellationToken cancellationToken = default)
{
var entryKey = GetEntryKey(tenantId, snapshotId, assetId);
var indexKey = GetIndexKey(tenantId, snapshotId);
await _entryCache.InvalidateAsync(entryKey, cancellationToken).ConfigureAwait(false);
await _assetIndex.RemoveAsync(indexKey, assetId, cancellationToken).ConfigureAwait(false);
await IncrementVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(1,
new KeyValuePair<string, object?>("operation", "invalidate"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
}
public async Task InvalidateSnapshotAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var indexKey = GetIndexKey(tenantId, snapshotId);
// Get all asset IDs from the index
var assetElements = await _assetIndex.GetByRankAsync(indexKey, 0, -1, cancellationToken: cancellationToken).ConfigureAwait(false);
var count = assetElements.Count;
foreach (var element in assetElements)
{
var entryKey = GetEntryKey(tenantId, snapshotId, element.Element);
await _entryCache.InvalidateAsync(entryKey, cancellationToken).ConfigureAwait(false);
}
// Delete the index
await _assetIndex.DeleteAsync(indexKey, cancellationToken).ConfigureAwait(false);
// Delete the version
var versionKey = GetVersionKey(tenantId, snapshotId);
await _versionCache.InvalidateAsync(versionKey, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(count,
new KeyValuePair<string, object?>("operation", "invalidate_snapshot"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} entries for snapshot {SnapshotId}", count, snapshotId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
// Invalidate all entries and indexes for the tenant using pattern matching
var pattern = $"{EntryKeyPrefix}:{tenantId}:*";
var entryCount = await _entryCache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false);
// Note: ISortedIndex doesn't have pattern-based deletion, so we can't easily clean up indexes
// This is a limitation of the abstraction - the Redis implementation handled this with KEYS scan
PolicyEngineTelemetry.EffectiveDecisionMapOperations.Add(entryCount,
new KeyValuePair<string, object?>("operation", "invalidate_tenant"),
new KeyValuePair<string, object?>("tenant_id", tenantId));
_logger.LogInformation("Invalidated {Count} entries for tenant {TenantId}", entryCount, tenantId);
}
public async Task<long> GetVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var versionKey = GetVersionKey(tenantId, snapshotId);
var result = await _versionCache.GetAsync(versionKey, cancellationToken).ConfigureAwait(false);
return result.HasValue ? result.Value : 0;
}
public async Task<long> IncrementVersionAsync(
string tenantId,
string snapshotId,
CancellationToken cancellationToken = default)
{
var versionKey = GetVersionKey(tenantId, snapshotId);
var current = await GetVersionAsync(tenantId, snapshotId, cancellationToken).ConfigureAwait(false);
var newVersion = current + 1;
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10)
};
await _versionCache.SetAsync(versionKey, newVersion, cacheOptions, cancellationToken).ConfigureAwait(false);
return newVersion;
}
public Task<EffectiveDecisionMapStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
// Stats require implementation-specific queries that aren't available through abstractions
// Return placeholder stats - a complete implementation would need transport-specific code
return Task.FromResult(new EffectiveDecisionMapStats
{
TotalEntries = 0,
TotalSnapshots = 0,
MemoryUsedBytes = null,
ExpiringWithinHour = 0,
LastEvictionAt = null,
LastEvictionCount = 0,
});
}
private static string GetEntryKey(string tenantId, string snapshotId, string assetId) =>
$"{EntryKeyPrefix}:{tenantId}:{snapshotId}:{assetId}";
private static string GetIndexKey(string tenantId, string snapshotId) =>
$"{IndexKeyPrefix}:{tenantId}:{snapshotId}";
private static string GetVersionKey(string tenantId, string snapshotId) =>
$"{VersionKeyPrefix}:{tenantId}:{snapshotId}";
}

View File

@@ -0,0 +1,584 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.ExceptionCache;
/// <summary>
/// Transport-agnostic exception effective cache using StellaOps.Messaging abstractions.
/// Works with any configured transport (Valkey, PostgreSQL, InMemory).
/// </summary>
internal sealed class MessagingExceptionEffectiveCache : IExceptionEffectiveCache
{
private readonly IDistributedCache<string, List<ExceptionCacheEntry>> _entryCache;
private readonly ISetStore<string, string> _exceptionIndex;
private readonly IDistributedCache<string, long> _versionCache;
private readonly IDistributedCache<string, Dictionary<string, string>> _statsCache;
private readonly IExceptionRepository _repository;
private readonly ILogger<MessagingExceptionEffectiveCache> _logger;
private readonly ExceptionCacheOptions _options;
private readonly TimeProvider _timeProvider;
private const string EntryKeyPrefix = "exc:entry";
private const string IndexKeyPrefix = "exc:index";
private const string VersionKeyPrefix = "exc:version";
private const string StatsKeyPrefix = "exc:stats";
public MessagingExceptionEffectiveCache(
IDistributedCacheFactory cacheFactory,
ISetStoreFactory setStoreFactory,
IExceptionRepository repository,
ILogger<MessagingExceptionEffectiveCache> logger,
IOptions<PolicyEngineOptions> options,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(cacheFactory);
ArgumentNullException.ThrowIfNull(setStoreFactory);
_entryCache = cacheFactory.Create<string, List<ExceptionCacheEntry>>(new CacheOptions { KeyPrefix = EntryKeyPrefix });
_exceptionIndex = setStoreFactory.Create<string, string>("exc-exception-index");
_versionCache = cacheFactory.Create<string, long>(new CacheOptions { KeyPrefix = VersionKeyPrefix });
_statsCache = cacheFactory.Create<string, Dictionary<string, string>>(new CacheOptions { KeyPrefix = StatsKeyPrefix });
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value.ExceptionCache ?? new ExceptionCacheOptions();
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExceptionCacheQueryResult> GetForAssetAsync(
string tenantId,
string assetId,
string? advisoryId,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
var entries = new List<ExceptionCacheEntry>();
var fromCache = false;
// Try specific advisory key first
if (advisoryId is not null)
{
var specificKey = GetAssetKey(tenantId, assetId, advisoryId);
var specificResult = await _entryCache.GetAsync(specificKey, cancellationToken).ConfigureAwait(false);
if (specificResult.HasValue && specificResult.Value is not null)
{
entries.AddRange(specificResult.Value);
fromCache = true;
}
}
// Also get "all" entries (exceptions without specific advisory)
var allKey = GetAssetKey(tenantId, assetId, null);
var allResult = await _entryCache.GetAsync(allKey, cancellationToken).ConfigureAwait(false);
if (allResult.HasValue && allResult.Value is not null)
{
entries.AddRange(allResult.Value);
fromCache = true;
}
// Filter by time and sort by priority
var validEntries = entries
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
sw.Stop();
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, fromCache ? "hit" : "miss");
return new ExceptionCacheQueryResult
{
Entries = validEntries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = sw.ElapsedMilliseconds,
};
}
public async Task<IReadOnlyDictionary<string, ExceptionCacheQueryResult>> GetBatchAsync(
string tenantId,
IReadOnlyList<string> assetIds,
DateTimeOffset asOf,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, ExceptionCacheQueryResult>(StringComparer.OrdinalIgnoreCase);
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
foreach (var assetId in assetIds)
{
var entries = ImmutableArray<ExceptionCacheEntry>.Empty;
var fromCache = false;
var allKey = GetAssetKey(tenantId, assetId, null);
var result = await _entryCache.GetAsync(allKey, cancellationToken).ConfigureAwait(false);
if (result.HasValue && result.Value is not null)
{
entries = result.Value
.Where(e => e.EffectiveFrom <= asOf && (e.ExpiresAt is null || e.ExpiresAt > asOf))
.OrderByDescending(e => e.Priority)
.ToImmutableArray();
fromCache = true;
}
results[assetId] = new ExceptionCacheQueryResult
{
Entries = entries,
FromCache = fromCache,
CacheVersion = version,
QueryDurationMs = 0,
};
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "batch_get");
return results;
}
public async Task SetAsync(
string tenantId,
ExceptionCacheEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var assetKey = GetAssetKey(tenantId, entry.AssetId, entry.AdvisoryId);
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
// Get existing entries for this asset
var existingResult = await _entryCache.GetAsync(assetKey, cancellationToken).ConfigureAwait(false);
var entries = existingResult.HasValue && existingResult.Value is not null
? existingResult.Value
: new List<ExceptionCacheEntry>();
// Remove existing entry for same exception if any
entries.RemoveAll(e => e.ExceptionId == entry.ExceptionId);
// Add new entry
entries.Add(entry);
var ttl = ComputeTtl(entry);
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
// Store entry
await _entryCache.SetAsync(assetKey, entries, cacheOptions, cancellationToken).ConfigureAwait(false);
// Update exception index
await _exceptionIndex.AddAsync(exceptionIndexKey, assetKey, cancellationToken).ConfigureAwait(false);
await _exceptionIndex.SetExpirationAsync(exceptionIndexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken)
.ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set");
}
public async Task SetBatchAsync(
string tenantId,
IEnumerable<ExceptionCacheEntry> entries,
CancellationToken cancellationToken = default)
{
var count = 0;
// Group entries by asset+advisory
var groupedEntries = entries
.GroupBy(e => GetAssetKey(tenantId, e.AssetId, e.AdvisoryId))
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var (assetKey, assetEntries) in groupedEntries)
{
var ttl = assetEntries.Max(ComputeTtl);
var cacheOptions = new CacheEntryOptions { TimeToLive = ttl };
await _entryCache.SetAsync(assetKey, assetEntries, cacheOptions, cancellationToken).ConfigureAwait(false);
// Update exception indexes
foreach (var entry in assetEntries)
{
var exceptionIndexKey = GetExceptionIndexKey(tenantId, entry.ExceptionId);
await _exceptionIndex.AddAsync(exceptionIndexKey, assetKey, cancellationToken).ConfigureAwait(false);
await _exceptionIndex.SetExpirationAsync(exceptionIndexKey, ttl.Add(TimeSpan.FromMinutes(5)), cancellationToken)
.ConfigureAwait(false);
}
count += assetEntries.Count;
}
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "set_batch");
_logger.LogDebug("Set {Count} exception cache entries for tenant {TenantId}", count, tenantId);
}
public async Task InvalidateExceptionAsync(
string tenantId,
string exceptionId,
CancellationToken cancellationToken = default)
{
var exceptionIndexKey = GetExceptionIndexKey(tenantId, exceptionId);
// Get all asset keys affected by this exception
var assetKeys = await _exceptionIndex.GetMembersAsync(exceptionIndexKey, cancellationToken).ConfigureAwait(false);
if (assetKeys.Count > 0)
{
// For each asset key, remove entries for this exception
foreach (var assetKey in assetKeys)
{
var result = await _entryCache.GetAsync(assetKey, cancellationToken).ConfigureAwait(false);
if (result.HasValue && result.Value is not null)
{
var entries = result.Value;
entries.RemoveAll(e => e.ExceptionId == exceptionId);
if (entries.Count > 0)
{
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes)
};
await _entryCache.SetAsync(assetKey, entries, cacheOptions, cancellationToken).ConfigureAwait(false);
}
else
{
await _entryCache.InvalidateAsync(assetKey, cancellationToken).ConfigureAwait(false);
}
}
}
}
// Delete the exception index
await _exceptionIndex.DeleteAsync(exceptionIndexKey, cancellationToken).ConfigureAwait(false);
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_exception");
_logger.LogInformation(
"Invalidated exception {ExceptionId} affecting {Count} assets for tenant {TenantId}",
exceptionId, assetKeys.Count, tenantId);
}
public async Task InvalidateAssetAsync(
string tenantId,
string assetId,
CancellationToken cancellationToken = default)
{
// Invalidate all keys for this asset using pattern
var pattern = $"{EntryKeyPrefix}:{tenantId}:{assetId}:*";
var count = await _entryCache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false);
// Increment version
await IncrementVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_asset");
_logger.LogDebug("Invalidated {Count} cache keys for asset {AssetId}", count, assetId);
}
public async Task InvalidateTenantAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
// Invalidate all entry keys for tenant
var entryPattern = $"{EntryKeyPrefix}:{tenantId}:*";
var entryCount = await _entryCache.InvalidateByPatternAsync(entryPattern, cancellationToken).ConfigureAwait(false);
// Invalidate version and stats
var versionKey = GetVersionKey(tenantId);
await _versionCache.InvalidateAsync(versionKey, cancellationToken).ConfigureAwait(false);
var statsKey = GetStatsKey(tenantId);
await _statsCache.InvalidateAsync(statsKey, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "invalidate_tenant");
_logger.LogInformation("Invalidated {Count} cache keys for tenant {TenantId}", entryCount, tenantId);
}
public async Task WarmAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.warm", ActivityKind.Internal);
activity?.SetTag("tenant_id", tenantId);
var sw = Stopwatch.StartNew();
var now = _timeProvider.GetUtcNow();
_logger.LogInformation("Starting cache warm for tenant {TenantId}", tenantId);
try
{
var exceptions = await _repository.GetAllAsync(
tenantId,
ExceptionStatus.Active,
limit: _options.MaxEntriesPerTenant,
offset: 0,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (exceptions.Count == 0)
{
_logger.LogDebug("No active exceptions to warm for tenant {TenantId}", tenantId);
return;
}
var entries = new List<ExceptionCacheEntry>();
foreach (var exception in exceptions)
{
entries.Add(new ExceptionCacheEntry
{
ExceptionId = exception.Id.ToString(),
AssetId = string.IsNullOrWhiteSpace(exception.ProjectId) ? "*" : exception.ProjectId!,
AdvisoryId = null,
CveId = null,
DecisionOverride = "allow",
ExceptionType = "waiver",
Priority = 0,
EffectiveFrom = exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
});
}
if (entries.Count > 0)
{
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
}
sw.Stop();
// Update warm stats
await UpdateWarmStatsAsync(tenantId, now, entries.Count, cancellationToken).ConfigureAwait(false);
PolicyEngineTelemetry.RecordExceptionCacheOperation(tenantId, "warm");
_logger.LogInformation(
"Warmed cache with {Count} entries from {ExceptionCount} exceptions for tenant {TenantId} in {Duration}ms",
entries.Count, exceptions.Count, tenantId, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to warm cache for tenant {TenantId}", tenantId);
PolicyEngineTelemetry.RecordError("exception_cache_warm", tenantId);
throw;
}
}
public async Task<ExceptionCacheSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
// Note: Full summary requires scanning keys which isn't efficient with abstractions
// Return placeholder data - complete implementation would need transport-specific code
var version = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ExceptionCacheSummary
{
TenantId = tenantId,
TotalEntries = 0,
UniqueExceptions = 0,
UniqueAssets = 0,
ByType = new Dictionary<string, int>(),
ByDecision = new Dictionary<string, int>(),
ExpiringWithinHour = 0,
CacheVersion = version,
ComputedAt = now,
};
}
public Task<ExceptionCacheStats> GetStatsAsync(
string? tenantId = null,
CancellationToken cancellationToken = default)
{
// Stats require implementation-specific queries that aren't available through abstractions
// Return placeholder stats - a complete implementation would need transport-specific code
return Task.FromResult(new ExceptionCacheStats
{
TotalEntries = 0,
TotalTenants = 0,
MemoryUsedBytes = null,
HitCount = 0,
MissCount = 0,
LastWarmAt = null,
LastInvalidationAt = null,
});
}
public async Task<long> GetVersionAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
var versionKey = GetVersionKey(tenantId);
var result = await _versionCache.GetAsync(versionKey, cancellationToken).ConfigureAwait(false);
return result.HasValue ? result.Value : 0;
}
public async Task HandleExceptionEventAsync(
ExceptionEvent exceptionEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(exceptionEvent);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"exception.cache.handle_event", ActivityKind.Internal);
activity?.SetTag("tenant_id", exceptionEvent.TenantId);
activity?.SetTag("event_type", exceptionEvent.EventType);
activity?.SetTag("exception_id", exceptionEvent.ExceptionId);
_logger.LogDebug(
"Handling exception event {EventType} for exception {ExceptionId} tenant {TenantId}",
exceptionEvent.EventType, exceptionEvent.ExceptionId, exceptionEvent.TenantId);
switch (exceptionEvent.EventType.ToLowerInvariant())
{
case "activated":
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "expired":
case "revoked":
case "deleted":
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "updated":
await InvalidateExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
case "created":
await WarmExceptionAsync(exceptionEvent.TenantId, exceptionEvent.ExceptionId, cancellationToken)
.ConfigureAwait(false);
break;
default:
_logger.LogWarning("Unknown exception event type: {EventType}", exceptionEvent.EventType);
break;
}
PolicyEngineTelemetry.RecordExceptionCacheOperation(exceptionEvent.TenantId, $"event_{exceptionEvent.EventType}");
}
private async Task WarmExceptionAsync(string tenantId, string exceptionId, CancellationToken cancellationToken)
{
if (!Guid.TryParse(exceptionId, out var exceptionGuid))
{
_logger.LogWarning("Unable to parse exception id {ExceptionId} for tenant {TenantId}", exceptionId, tenantId);
return;
}
var exception = await _repository.GetByIdAsync(tenantId, exceptionGuid, cancellationToken)
.ConfigureAwait(false);
if (exception is null || exception.Status != ExceptionStatus.Active)
{
return;
}
var now = _timeProvider.GetUtcNow();
var entries = new List<ExceptionCacheEntry>
{
new ExceptionCacheEntry
{
ExceptionId = exception.Id.ToString(),
AssetId = string.IsNullOrWhiteSpace(exception.ProjectId) ? "*" : exception.ProjectId!,
AdvisoryId = null,
CveId = null,
DecisionOverride = "allow",
ExceptionType = "waiver",
Priority = 0,
EffectiveFrom = exception.CreatedAt,
ExpiresAt = exception.ExpiresAt,
CachedAt = now,
ExceptionName = exception.Name,
}
};
await SetBatchAsync(tenantId, entries, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Warmed cache with {Count} entries for exception {ExceptionId}",
entries.Count, exceptionId);
}
private async Task<long> IncrementVersionAsync(string tenantId, CancellationToken cancellationToken)
{
var versionKey = GetVersionKey(tenantId);
var current = await GetVersionAsync(tenantId, cancellationToken).ConfigureAwait(false);
var newVersion = current + 1;
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 10)
};
await _versionCache.SetAsync(versionKey, newVersion, cacheOptions, cancellationToken).ConfigureAwait(false);
return newVersion;
}
private async Task UpdateWarmStatsAsync(string tenantId, DateTimeOffset warmAt, int count, CancellationToken cancellationToken)
{
var statsKey = GetStatsKey(tenantId);
var stats = new Dictionary<string, string>
{
["lastWarmAt"] = warmAt.ToString("O"),
["lastWarmCount"] = count.ToString(),
};
var cacheOptions = new CacheEntryOptions
{
TimeToLive = TimeSpan.FromMinutes(_options.DefaultTtlMinutes + 30)
};
await _statsCache.SetAsync(statsKey, stats, cacheOptions, cancellationToken).ConfigureAwait(false);
}
private TimeSpan ComputeTtl(ExceptionCacheEntry entry)
{
if (entry.ExpiresAt.HasValue)
{
var ttl = entry.ExpiresAt.Value - _timeProvider.GetUtcNow();
if (ttl > TimeSpan.Zero)
{
return ttl;
}
}
return TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
}
private static string GetAssetKey(string tenantId, string assetId, string? advisoryId) =>
$"{tenantId}:{assetId}:{advisoryId ?? "all"}";
private static string GetExceptionIndexKey(string tenantId, string exceptionId) =>
$"{tenantId}:idx:{exceptionId}";
private static string GetVersionKey(string tenantId) =>
$"{tenantId}";
private static string GetStatsKey(string tenantId) =>
$"{tenantId}";
}

View File

@@ -0,0 +1,332 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Result of a policy gate evaluation.
/// </summary>
public sealed record PolicyGateDecision
{
/// <summary>
/// Unique identifier for this gate decision.
/// </summary>
[JsonPropertyName("gateId")]
public required string GateId { get; init; }
/// <summary>
/// The VEX status that was requested.
/// </summary>
[JsonPropertyName("requestedStatus")]
public required string RequestedStatus { get; init; }
/// <summary>
/// Subject of the decision (vuln, purl, symbol).
/// </summary>
[JsonPropertyName("subject")]
public required PolicyGateSubject Subject { get; init; }
/// <summary>
/// Evidence used in the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required PolicyGateEvidence Evidence { get; init; }
/// <summary>
/// Individual gate results.
/// </summary>
[JsonPropertyName("gates")]
public required ImmutableArray<PolicyGateResult> Gates { get; init; }
/// <summary>
/// Overall decision (allow, block, warn).
/// </summary>
[JsonPropertyName("decision")]
public required PolicyGateDecisionType Decision { get; init; }
/// <summary>
/// Advisory message if decision includes warnings.
/// </summary>
[JsonPropertyName("advisory")]
public string? Advisory { get; init; }
/// <summary>
/// Name of the gate that blocked, if blocked.
/// </summary>
[JsonPropertyName("blockedBy")]
public string? BlockedBy { get; init; }
/// <summary>
/// Reason for blocking.
/// </summary>
[JsonPropertyName("blockReason")]
public string? BlockReason { get; init; }
/// <summary>
/// Suggestion for resolving a block.
/// </summary>
[JsonPropertyName("suggestion")]
public string? Suggestion { get; init; }
/// <summary>
/// Timestamp when the decision was made.
/// </summary>
[JsonPropertyName("decidedAt")]
public required DateTimeOffset DecidedAt { get; init; }
}
/// <summary>
/// Subject of a policy gate decision.
/// </summary>
public sealed record PolicyGateSubject
{
/// <summary>
/// Vulnerability identifier.
/// </summary>
[JsonPropertyName("vulnId")]
public string? VulnId { get; init; }
/// <summary>
/// Package URL.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Symbol identifier.
/// </summary>
[JsonPropertyName("symbolId")]
public string? SymbolId { get; init; }
/// <summary>
/// Scan identifier.
/// </summary>
[JsonPropertyName("scanId")]
public string? ScanId { get; init; }
}
/// <summary>
/// Evidence used in a policy gate decision.
/// </summary>
public sealed record PolicyGateEvidence
{
/// <summary>
/// v1 lattice state code (U, SR, SU, RO, RU, CR, CU, X).
/// </summary>
[JsonPropertyName("latticeState")]
public string? LatticeState { get; init; }
/// <summary>
/// Uncertainty tier (T1, T2, T3, T4).
/// </summary>
[JsonPropertyName("uncertaintyTier")]
public string? UncertaintyTier { get; init; }
/// <summary>
/// BLAKE3 hash of the callgraph.
/// </summary>
[JsonPropertyName("graphHash")]
public string? GraphHash { get; init; }
/// <summary>
/// Risk score incorporating uncertainty.
/// </summary>
[JsonPropertyName("riskScore")]
public double? RiskScore { get; init; }
/// <summary>
/// Reachability confidence (0-1).
/// </summary>
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
/// <summary>
/// Whether runtime evidence exists.
/// </summary>
[JsonPropertyName("hasRuntimeEvidence")]
public bool HasRuntimeEvidence { get; init; }
/// <summary>
/// Path length from entry point to vulnerable symbol (-1 if unreachable).
/// </summary>
[JsonPropertyName("pathLength")]
public int? PathLength { get; init; }
}
/// <summary>
/// Result of a single gate evaluation.
/// </summary>
public sealed record PolicyGateResult
{
/// <summary>
/// Name of the gate.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Result of the gate evaluation.
/// </summary>
[JsonPropertyName("result")]
public required PolicyGateResultType Result { get; init; }
/// <summary>
/// Reason for the result.
/// </summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>
/// Additional note if result is pass_with_note.
/// </summary>
[JsonPropertyName("note")]
public string? Note { get; init; }
}
/// <summary>
/// Overall gate decision type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyGateDecisionType>))]
public enum PolicyGateDecisionType
{
/// <summary>
/// Status change is allowed.
/// </summary>
[JsonPropertyName("allow")]
Allow,
/// <summary>
/// Status change is blocked.
/// </summary>
[JsonPropertyName("block")]
Block,
/// <summary>
/// Status change is allowed with warning.
/// </summary>
[JsonPropertyName("warn")]
Warn
}
/// <summary>
/// Individual gate result type.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PolicyGateResultType>))]
public enum PolicyGateResultType
{
/// <summary>
/// Gate passed.
/// </summary>
[JsonPropertyName("pass")]
Pass,
/// <summary>
/// Gate passed with advisory note.
/// </summary>
[JsonPropertyName("pass_with_note")]
PassWithNote,
/// <summary>
/// Gate emitted a warning.
/// </summary>
[JsonPropertyName("warn")]
Warn,
/// <summary>
/// Gate blocked the request.
/// </summary>
[JsonPropertyName("block")]
Block,
/// <summary>
/// Gate was skipped (not applicable).
/// </summary>
[JsonPropertyName("skip")]
Skip
}
/// <summary>
/// Request to evaluate policy gates for a VEX status change.
/// </summary>
public sealed record PolicyGateRequest
{
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Vulnerability identifier.
/// </summary>
public string? VulnId { get; init; }
/// <summary>
/// Package URL.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// Symbol identifier.
/// </summary>
public string? SymbolId { get; init; }
/// <summary>
/// Scan identifier.
/// </summary>
public string? ScanId { get; init; }
/// <summary>
/// Requested VEX status (not_affected, affected, under_investigation, fixed).
/// </summary>
public required string RequestedStatus { get; init; }
/// <summary>
/// Justification for the status (required for some statuses).
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// v1 lattice state code.
/// </summary>
public string? LatticeState { get; init; }
/// <summary>
/// Uncertainty tier.
/// </summary>
public string? UncertaintyTier { get; init; }
/// <summary>
/// BLAKE3 graph hash.
/// </summary>
public string? GraphHash { get; init; }
/// <summary>
/// Risk score.
/// </summary>
public double? RiskScore { get; init; }
/// <summary>
/// Confidence score.
/// </summary>
public double? Confidence { get; init; }
/// <summary>
/// Whether runtime evidence exists.
/// </summary>
public bool HasRuntimeEvidence { get; init; }
/// <summary>
/// Path length from entry point.
/// </summary>
public int? PathLength { get; init; }
/// <summary>
/// Whether to allow override (requires permission).
/// </summary>
public bool AllowOverride { get; init; }
/// <summary>
/// Override justification if AllowOverride is true.
/// </summary>
public string? OverrideJustification { get; init; }
}

View File

@@ -0,0 +1,746 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Evaluates policy gates for VEX status transitions.
/// Gates ensure that status changes are backed by sufficient evidence.
/// </summary>
public interface IPolicyGateEvaluator
{
/// <summary>
/// Evaluates all policy gates for a VEX status change request.
/// </summary>
/// <param name="request">The gate evaluation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The gate decision.</returns>
Task<PolicyGateDecision> EvaluateAsync(PolicyGateRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of <see cref="IPolicyGateEvaluator"/>.
/// </summary>
public sealed class PolicyGateEvaluator : IPolicyGateEvaluator
{
private readonly IOptionsMonitor<PolicyGateOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyGateEvaluator> _logger;
// VEX statuses
private const string StatusNotAffected = "not_affected";
private const string StatusAffected = "affected";
private const string StatusUnderInvestigation = "under_investigation";
private const string StatusFixed = "fixed";
// Lattice states (v1)
private const string LatticeUnknown = "U";
private const string LatticeStaticallyReachable = "SR";
private const string LatticeStaticallyUnreachable = "SU";
private const string LatticeRuntimeObserved = "RO";
private const string LatticeRuntimeUnobserved = "RU";
private const string LatticeConfirmedReachable = "CR";
private const string LatticeConfirmedUnreachable = "CU";
private const string LatticeContested = "X";
// Uncertainty tiers
private const string TierT1 = "T1";
private const string TierT2 = "T2";
private const string TierT3 = "T3";
private const string TierT4 = "T4";
public PolicyGateEvaluator(
IOptionsMonitor<PolicyGateOptions> options,
TimeProvider timeProvider,
ILogger<PolicyGateEvaluator> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public Task<PolicyGateDecision> EvaluateAsync(PolicyGateRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var options = _options.CurrentValue;
var now = _timeProvider.GetUtcNow();
// Build gate ID
var gateId = $"gate:vex:{request.RequestedStatus}:{now:O}";
// Build subject
var subject = new PolicyGateSubject
{
VulnId = request.VulnId,
Purl = request.Purl,
SymbolId = request.SymbolId,
ScanId = request.ScanId
};
// Build evidence
var evidence = new PolicyGateEvidence
{
LatticeState = request.LatticeState,
UncertaintyTier = request.UncertaintyTier,
GraphHash = request.GraphHash,
RiskScore = request.RiskScore,
Confidence = request.Confidence,
HasRuntimeEvidence = request.HasRuntimeEvidence,
PathLength = request.PathLength
};
// If gates are disabled, allow everything
if (!options.Enabled)
{
return Task.FromResult(CreateAllowDecision(gateId, request.RequestedStatus, subject, evidence, now, "Gates disabled"));
}
// Evaluate gates in order: Evidence -> Lattice -> Uncertainty -> Confidence
var gateResults = new List<PolicyGateResult>(4);
string? blockedBy = null;
string? blockReason = null;
string? suggestion = null;
var warnings = new List<string>();
// 1. Evidence Completeness Gate
var evidenceResult = EvaluateEvidenceCompletenessGate(request, options.EvidenceCompleteness);
gateResults.Add(evidenceResult);
if (evidenceResult.Result == PolicyGateResultType.Block)
{
blockedBy = evidenceResult.Name;
blockReason = evidenceResult.Reason;
suggestion = GetEvidenceSuggestion(request.RequestedStatus);
}
else if (evidenceResult.Result == PolicyGateResultType.Warn || evidenceResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(evidenceResult.Reason);
}
// 2. Lattice State Gate (only if not already blocked)
if (blockedBy is null)
{
var latticeResult = EvaluateLatticeStateGate(request, options.LatticeState);
gateResults.Add(latticeResult);
if (latticeResult.Result == PolicyGateResultType.Block)
{
blockedBy = latticeResult.Name;
blockReason = latticeResult.Reason;
suggestion = GetLatticeSuggestion(request.LatticeState, request.RequestedStatus);
}
else if (latticeResult.Result == PolicyGateResultType.Warn || latticeResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(latticeResult.Note ?? latticeResult.Reason);
}
}
// 3. Uncertainty Tier Gate (only if not already blocked)
if (blockedBy is null)
{
var uncertaintyResult = EvaluateUncertaintyTierGate(request, options.UncertaintyTier);
gateResults.Add(uncertaintyResult);
if (uncertaintyResult.Result == PolicyGateResultType.Block)
{
blockedBy = uncertaintyResult.Name;
blockReason = uncertaintyResult.Reason;
suggestion = GetUncertaintySuggestion(request.UncertaintyTier);
}
else if (uncertaintyResult.Result == PolicyGateResultType.Warn || uncertaintyResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(uncertaintyResult.Note ?? uncertaintyResult.Reason);
}
}
// 4. Confidence Threshold Gate (only if not already blocked)
if (blockedBy is null && request.Confidence.HasValue)
{
var confidenceResult = EvaluateConfidenceGate(request, options.EvidenceCompleteness);
gateResults.Add(confidenceResult);
if (confidenceResult.Result == PolicyGateResultType.Warn || confidenceResult.Result == PolicyGateResultType.PassWithNote)
{
warnings.Add(confidenceResult.Note ?? confidenceResult.Reason);
}
}
// Build final decision
PolicyGateDecisionType decision;
string? advisory = null;
if (blockedBy is not null)
{
// Check for override
if (request.AllowOverride && CanOverride(request, options.Override))
{
decision = PolicyGateDecisionType.Warn;
advisory = $"Override accepted: {request.OverrideJustification}";
_logger.LogInformation(
"Gate {Gate} overridden for {Status} on {Vuln}/{Purl}: {Justification}",
blockedBy, request.RequestedStatus, request.VulnId, request.Purl, request.OverrideJustification);
}
else
{
decision = PolicyGateDecisionType.Block;
_logger.LogInformation(
"Gate {Gate} blocked {Status} on {Vuln}/{Purl}: {Reason}",
blockedBy, request.RequestedStatus, request.VulnId, request.Purl, blockReason);
}
}
else if (warnings.Count > 0)
{
decision = PolicyGateDecisionType.Warn;
advisory = string.Join("; ", warnings);
}
else
{
decision = PolicyGateDecisionType.Allow;
}
var result = new PolicyGateDecision
{
GateId = gateId,
RequestedStatus = request.RequestedStatus,
Subject = subject,
Evidence = evidence,
Gates = gateResults.ToImmutableArray(),
Decision = decision,
Advisory = advisory,
BlockedBy = blockedBy,
BlockReason = blockReason,
Suggestion = suggestion,
DecidedAt = now
};
return Task.FromResult(result);
}
private PolicyGateResult EvaluateEvidenceCompletenessGate(PolicyGateRequest request, EvidenceCompletenessGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
switch (status)
{
case StatusNotAffected:
// Require graph hash
if (options.RequireGraphHashForNotAffected && string.IsNullOrWhiteSpace(request.GraphHash))
{
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Block,
Reason = "graphHash (DSSE-attested) is required for not_affected"
};
}
// Require path analysis
if (options.RequirePathAnalysisForNotAffected && request.PathLength is null)
{
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Block,
Reason = "pathAnalysis.pathLength is required for not_affected"
};
}
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Pass,
Reason = "Required evidence present for not_affected"
};
case StatusAffected:
if (options.WarnNoEvidenceForAffected &&
string.IsNullOrWhiteSpace(request.GraphHash) &&
!request.HasRuntimeEvidence)
{
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Warn,
Reason = "No graphHash or runtimeProbe evidence for affected status"
};
}
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Pass,
Reason = "Evidence present for affected"
};
case StatusUnderInvestigation:
case StatusFixed:
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Pass,
Reason = $"No evidence requirements for {status}"
};
default:
return new PolicyGateResult
{
Name = "EvidenceCompleteness",
Result = PolicyGateResultType.Skip,
Reason = $"Unknown status: {status}"
};
}
}
private PolicyGateResult EvaluateLatticeStateGate(PolicyGateRequest request, LatticeStateGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
var latticeState = request.LatticeState?.ToUpperInvariant() ?? LatticeUnknown;
switch (status)
{
case StatusNotAffected:
return EvaluateLatticeForNotAffected(latticeState, request.Justification, options);
case StatusAffected:
return EvaluateLatticeForAffected(latticeState);
case StatusUnderInvestigation:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "Any lattice state allows under_investigation (safe default)"
};
case StatusFixed:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "Any lattice state allows fixed (remediation action)"
};
default:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Skip,
Reason = $"Unknown status: {status}"
};
}
}
private PolicyGateResult EvaluateLatticeForNotAffected(string latticeState, string? justification, LatticeStateGateOptions options)
{
switch (latticeState)
{
case LatticeConfirmedUnreachable:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "CU (ConfirmedUnreachable) allows not_affected"
};
case LatticeStaticallyUnreachable:
if (!options.AllowSUForNotAffected)
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "SU (StaticallyUnreachable) not allowed for not_affected (configuration)"
};
}
if (options.RequireJustificationForWeakStates && string.IsNullOrWhiteSpace(justification))
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "SU requires justification for not_affected"
};
}
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.PassWithNote,
Reason = "SU allows not_affected with warning",
Note = "Static analysis only; no runtime confirmation"
};
case LatticeRuntimeUnobserved:
if (!options.AllowRUForNotAffected)
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "RU (RuntimeUnobserved) not allowed for not_affected (configuration)"
};
}
if (options.RequireJustificationForWeakStates && string.IsNullOrWhiteSpace(justification))
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "RU requires justification for not_affected"
};
}
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.PassWithNote,
Reason = "RU allows not_affected with warning",
Note = "Runtime unobserved; may be reachable but untested code path"
};
case LatticeContested:
if (options.BlockContestedForDefinitiveStatuses)
{
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = "X (Contested) incompatible with not_affected; conflicting static/runtime evidence"
};
}
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Warn,
Reason = "X (Contested) requires triage before not_affected"
};
case LatticeUnknown:
case LatticeStaticallyReachable:
case LatticeRuntimeObserved:
case LatticeConfirmedReachable:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = $"{latticeState} incompatible with not_affected"
};
default:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Block,
Reason = $"Unknown lattice state {latticeState} cannot justify not_affected"
};
}
}
private PolicyGateResult EvaluateLatticeForAffected(string latticeState)
{
switch (latticeState)
{
case LatticeConfirmedReachable:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "CR (ConfirmedReachable) confirms affected"
};
case LatticeStaticallyReachable:
case LatticeRuntimeObserved:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = $"{latticeState} supports affected"
};
case LatticeUnknown:
case LatticeStaticallyUnreachable:
case LatticeRuntimeUnobserved:
case LatticeConfirmedUnreachable:
case LatticeContested:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Warn,
Reason = $"{latticeState} may indicate false positive for affected",
Note = "Consider review: evidence suggests code may not be reachable"
};
default:
return new PolicyGateResult
{
Name = "LatticeState",
Result = PolicyGateResultType.Pass,
Reason = "Unknown lattice state; allowing affected as safe default"
};
}
}
private PolicyGateResult EvaluateUncertaintyTierGate(PolicyGateRequest request, UncertaintyTierGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
var tier = request.UncertaintyTier?.ToUpperInvariant() ?? TierT4;
switch (status)
{
case StatusNotAffected:
return EvaluateUncertaintyForNotAffected(tier, options);
case StatusAffected:
return EvaluateUncertaintyForAffected(tier, options);
case StatusUnderInvestigation:
case StatusFixed:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = $"No uncertainty requirements for {status}"
};
default:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Skip,
Reason = $"Unknown status: {status}"
};
}
}
private PolicyGateResult EvaluateUncertaintyForNotAffected(string tier, UncertaintyTierGateOptions options)
{
switch (tier)
{
case TierT1:
if (options.BlockT1ForNotAffected)
{
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Block,
Reason = "T1 (High) uncertainty blocks not_affected; require human review"
};
}
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = "T1 (High) uncertainty; not_affected may be premature"
};
case TierT2:
if (options.WarnT2ForNotAffected)
{
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = "T2 (Medium) uncertainty; explicit override recommended",
Note = "Flag for review; decisions may need re-evaluation"
};
}
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "T2 (Medium) uncertainty allowed for not_affected"
};
case TierT3:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.PassWithNote,
Reason = "T3 (Low) uncertainty allows not_affected",
Note = "Advisory: Low uncertainty present"
};
case TierT4:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "T4 (Negligible) uncertainty; not_affected allowed"
};
default:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = $"Unknown uncertainty tier {tier}"
};
}
}
private PolicyGateResult EvaluateUncertaintyForAffected(string tier, UncertaintyTierGateOptions options)
{
switch (tier)
{
case TierT1:
if (options.ReviewT1ForAffected)
{
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Warn,
Reason = "T1 (High) uncertainty for affected; may be false positive",
Note = "Review required: high uncertainty suggests reachability analysis incomplete"
};
}
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "T1 (High) uncertainty; affected allowed as safe default"
};
case TierT2:
case TierT3:
case TierT4:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = $"{tier} uncertainty allows affected"
};
default:
return new PolicyGateResult
{
Name = "UncertaintyTier",
Result = PolicyGateResultType.Pass,
Reason = "Unknown uncertainty tier; allowing affected as safe default"
};
}
}
private PolicyGateResult EvaluateConfidenceGate(PolicyGateRequest request, EvidenceCompletenessGateOptions options)
{
var status = request.RequestedStatus?.ToLowerInvariant() ?? string.Empty;
var confidence = request.Confidence ?? 1.0;
if (status == StatusNotAffected)
{
if (confidence < options.MinConfidenceWarning)
{
return new PolicyGateResult
{
Name = "ConfidenceThreshold",
Result = PolicyGateResultType.Warn,
Reason = string.Create(CultureInfo.InvariantCulture, $"Confidence {confidence:P0} below warning threshold {options.MinConfidenceWarning:P0}"),
Note = "Low confidence in reachability analysis"
};
}
if (confidence < options.MinConfidenceForNotAffected)
{
return new PolicyGateResult
{
Name = "ConfidenceThreshold",
Result = PolicyGateResultType.Warn,
Reason = string.Create(CultureInfo.InvariantCulture, $"Confidence {confidence:P0} below recommended {options.MinConfidenceForNotAffected:P0}"),
Note = "Consider gathering additional evidence"
};
}
}
return new PolicyGateResult
{
Name = "ConfidenceThreshold",
Result = PolicyGateResultType.Pass,
Reason = string.Create(CultureInfo.InvariantCulture, $"Confidence {confidence:P0} meets requirements")
};
}
private static bool CanOverride(PolicyGateRequest request, OverrideOptions options)
{
if (!request.AllowOverride)
{
return false;
}
if (options.RequireJustification)
{
if (string.IsNullOrWhiteSpace(request.OverrideJustification))
{
return false;
}
if (request.OverrideJustification.Length < options.MinJustificationLength)
{
return false;
}
}
return true;
}
private static PolicyGateDecision CreateAllowDecision(
string gateId,
string requestedStatus,
PolicyGateSubject subject,
PolicyGateEvidence evidence,
DateTimeOffset decidedAt,
string reason)
{
return new PolicyGateDecision
{
GateId = gateId,
RequestedStatus = requestedStatus,
Subject = subject,
Evidence = evidence,
Gates = ImmutableArray.Create(new PolicyGateResult
{
Name = "Bypass",
Result = PolicyGateResultType.Pass,
Reason = reason
}),
Decision = PolicyGateDecisionType.Allow,
Advisory = reason,
DecidedAt = decidedAt
};
}
private static string GetEvidenceSuggestion(string status) => status switch
{
StatusNotAffected => "Submit DSSE-attested call graph with path analysis",
StatusAffected => "Consider providing graph hash or runtime probe evidence",
_ => "Provide additional evidence"
};
private static string GetLatticeSuggestion(string? latticeState, string status)
{
if (status == StatusNotAffected)
{
return latticeState switch
{
LatticeContested => "Resolve contested state through triage before claiming not_affected",
LatticeStaticallyReachable or LatticeRuntimeObserved or LatticeConfirmedReachable =>
"Submit runtime probe evidence showing unreachability or change to under_investigation",
LatticeUnknown => "Run reachability analysis to determine lattice state",
_ => "Provide evidence to support not_affected claim"
};
}
return "Review evidence and adjust status accordingly";
}
private static string GetUncertaintySuggestion(string? tier) => tier switch
{
TierT1 => "Reduce uncertainty: resolve missing symbol resolution, verify PURL mappings, or provide trusted advisory sources",
TierT2 => "Consider providing override with justification or reducing uncertainty through additional analysis",
_ => "Review uncertainty sources and address where possible"
};
}

View File

@@ -0,0 +1,136 @@
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Configuration options for policy gates.
/// </summary>
public sealed class PolicyGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "PolicyGates";
/// <summary>
/// Lattice state gate options.
/// </summary>
public LatticeStateGateOptions LatticeState { get; set; } = new();
/// <summary>
/// Uncertainty tier gate options.
/// </summary>
public UncertaintyTierGateOptions UncertaintyTier { get; set; } = new();
/// <summary>
/// Evidence completeness gate options.
/// </summary>
public EvidenceCompletenessGateOptions EvidenceCompleteness { get; set; } = new();
/// <summary>
/// Override mechanism options.
/// </summary>
public OverrideOptions Override { get; set; } = new();
/// <summary>
/// Whether gates are enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}
/// <summary>
/// Configuration options for the lattice state gate.
/// </summary>
public sealed class LatticeStateGateOptions
{
/// <summary>
/// Allow StaticallyUnreachable (SU) state for not_affected with warning.
/// </summary>
public bool AllowSUForNotAffected { get; set; } = true;
/// <summary>
/// Allow RuntimeUnobserved (RU) state for not_affected with warning.
/// </summary>
public bool AllowRUForNotAffected { get; set; } = true;
/// <summary>
/// Require justification for weak states (SU, RU).
/// </summary>
public bool RequireJustificationForWeakStates { get; set; } = true;
/// <summary>
/// Block contested (X) state for definitive statuses.
/// </summary>
public bool BlockContestedForDefinitiveStatuses { get; set; } = true;
}
/// <summary>
/// Configuration options for the uncertainty tier gate.
/// </summary>
public sealed class UncertaintyTierGateOptions
{
/// <summary>
/// Block T1 (High) uncertainty for not_affected.
/// </summary>
public bool BlockT1ForNotAffected { get; set; } = true;
/// <summary>
/// Warn for T2 (Medium) uncertainty for not_affected.
/// </summary>
public bool WarnT2ForNotAffected { get; set; } = true;
/// <summary>
/// Require explicit override for T1 affected (possible false positive).
/// </summary>
public bool ReviewT1ForAffected { get; set; } = true;
}
/// <summary>
/// Configuration options for the evidence completeness gate.
/// </summary>
public sealed class EvidenceCompletenessGateOptions
{
/// <summary>
/// Require graph hash for not_affected.
/// </summary>
public bool RequireGraphHashForNotAffected { get; set; } = true;
/// <summary>
/// Minimum confidence threshold for not_affected.
/// </summary>
public double MinConfidenceForNotAffected { get; set; } = 0.8;
/// <summary>
/// Confidence threshold that triggers a warning.
/// </summary>
public double MinConfidenceWarning { get; set; } = 0.6;
/// <summary>
/// Require path analysis for not_affected.
/// </summary>
public bool RequirePathAnalysisForNotAffected { get; set; } = true;
/// <summary>
/// Warn if no graph hash or runtime probe for affected.
/// </summary>
public bool WarnNoEvidenceForAffected { get; set; } = true;
}
/// <summary>
/// Configuration options for override mechanism.
/// </summary>
public sealed class OverrideOptions
{
/// <summary>
/// Default expiration period for overrides in days.
/// </summary>
public int DefaultExpirationDays { get; set; } = 30;
/// <summary>
/// Require justification for all overrides.
/// </summary>
public bool RequireJustification { get; set; } = true;
/// <summary>
/// Minimum justification length.
/// </summary>
public int MinJustificationLength { get; set; } = 20;
}

View File

@@ -599,6 +599,8 @@ internal sealed class PolicyRuntimeEvaluationService
Method: fact.Method.ToString().ToLowerInvariant(),
EvidenceRef: fact.EvidenceRef ?? fact.EvidenceHash);
reachability = ApplyReachabilityEvidenceGate(reachability, fact.EvidenceRef);
ReachabilityFacts.ReachabilityFactsTelemetry.RecordFactApplied(reachability.State);
return request with { Reachability = reachability };
}
@@ -652,6 +654,8 @@ internal sealed class PolicyRuntimeEvaluationService
Method: fact.Method.ToString().ToLowerInvariant(),
EvidenceRef: fact.EvidenceRef ?? fact.EvidenceHash);
reachability = ApplyReachabilityEvidenceGate(reachability, fact.EvidenceRef);
ReachabilityFacts.ReachabilityFactsTelemetry.RecordFactApplied(reachability.State);
enriched.Add(request with { Reachability = reachability });
}
@@ -664,5 +668,21 @@ internal sealed class PolicyRuntimeEvaluationService
return enriched;
}
}
private static PolicyEvaluationReachability ApplyReachabilityEvidenceGate(
PolicyEvaluationReachability reachability,
string? evidenceRef)
{
if (!reachability.IsUnreachable)
{
return reachability;
}
if (!reachability.IsHighConfidence || string.IsNullOrWhiteSpace(evidenceRef))
{
return reachability with { State = "under_investigation" };
}
return reachability;
}
}

View File

@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />

View File

@@ -0,0 +1,7 @@
# Policy Engine · Local Tasks
This file mirrors sprint work for the Policy Engine module.
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DOING | Gate `unreachable` reachability facts: missing evidence ref or low confidence => `under_investigation`; add tests and docs. |