up
This commit is contained in:
@@ -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}";
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
332
src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateDecision.cs
Normal file
332
src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateDecision.cs
Normal 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; }
|
||||
}
|
||||
746
src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs
Normal file
746
src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
136
src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateOptions.cs
Normal file
136
src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateOptions.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
7
src/Policy/StellaOps.Policy.Engine/TASKS.md
Normal file
7
src/Policy/StellaOps.Policy.Engine/TASKS.md
Normal 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. |
|
||||
Reference in New Issue
Block a user