Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Caching;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
@@ -86,6 +87,7 @@ internal sealed record BatchEvaluationResultDto(
|
||||
IReadOnlyDictionary<string, string> Annotations,
|
||||
IReadOnlyList<string> Warnings,
|
||||
PolicyExceptionApplication? AppliedException,
|
||||
ConfidenceScore? Confidence,
|
||||
string CorrelationId,
|
||||
bool Cached,
|
||||
CacheSource CacheSource,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.BuildGate;
|
||||
|
||||
/// <summary>
|
||||
/// Build gate that checks recheck policies before allowing deployment.
|
||||
/// </summary>
|
||||
public sealed class ExceptionRecheckGate : IBuildGate
|
||||
{
|
||||
private readonly IExceptionEvaluator _exceptionEvaluator;
|
||||
private readonly IRecheckEvaluationService _recheckService;
|
||||
private readonly ILogger<ExceptionRecheckGate> _logger;
|
||||
|
||||
public ExceptionRecheckGate(
|
||||
IExceptionEvaluator exceptionEvaluator,
|
||||
IRecheckEvaluationService recheckService,
|
||||
ILogger<ExceptionRecheckGate> logger)
|
||||
{
|
||||
_exceptionEvaluator = exceptionEvaluator ?? throw new ArgumentNullException(nameof(exceptionEvaluator));
|
||||
_recheckService = recheckService ?? throw new ArgumentNullException(nameof(recheckService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string GateName => "exception-recheck";
|
||||
public int Priority => 100;
|
||||
|
||||
public async Task<BuildGateResult> EvaluateAsync(
|
||||
BuildGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Evaluating exception recheck gate for artifact {Artifact}",
|
||||
context.ArtifactDigest);
|
||||
|
||||
var evaluation = await _exceptionEvaluator.EvaluateAsync(new FindingContext
|
||||
{
|
||||
ArtifactDigest = context.ArtifactDigest,
|
||||
Environment = context.Environment,
|
||||
TenantId = context.TenantId
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
var blockers = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var exception in evaluation.MatchingExceptions)
|
||||
{
|
||||
if (exception.RecheckPolicy is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var evalContext = new RecheckEvaluationContext
|
||||
{
|
||||
ArtifactDigest = context.ArtifactDigest,
|
||||
Environment = context.Environment,
|
||||
EvaluatedAt = context.EvaluatedAt,
|
||||
ReachGraphChanged = context.ReachGraphChanged,
|
||||
EpssScore = context.EpssScore,
|
||||
CvssScore = context.CvssScore,
|
||||
UnknownsCount = context.UnknownsCount,
|
||||
NewCveInPackage = context.NewCveInPackage,
|
||||
KevFlagged = context.KevFlagged,
|
||||
VexStatusChanged = context.VexStatusChanged,
|
||||
PackageVersionChanged = context.PackageVersionChanged
|
||||
};
|
||||
|
||||
var result = await _recheckService.EvaluateAsync(exception, evalContext, ct).ConfigureAwait(false);
|
||||
if (!result.IsTriggered)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var triggered in result.TriggeredConditions)
|
||||
{
|
||||
var message = $"Exception {exception.ExceptionId}: {triggered.Description} ({triggered.Action})";
|
||||
|
||||
if (triggered.Action is RecheckAction.Block or RecheckAction.Revoke or RecheckAction.RequireReapproval)
|
||||
{
|
||||
blockers.Add(message);
|
||||
}
|
||||
else if (triggered.Action == RecheckAction.Warn)
|
||||
{
|
||||
warnings.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (blockers.Count > 0)
|
||||
{
|
||||
return new BuildGateResult
|
||||
{
|
||||
Passed = false,
|
||||
GateName = GateName,
|
||||
Message = $"Recheck policy blocking: {string.Join("; ", blockers)}",
|
||||
Blockers = blockers.ToImmutableArray(),
|
||||
Warnings = warnings.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
return new BuildGateResult
|
||||
{
|
||||
Passed = true,
|
||||
GateName = GateName,
|
||||
Message = warnings.Count > 0
|
||||
? $"Passed with {warnings.Count} warning(s)"
|
||||
: "All exception recheck policies satisfied",
|
||||
Blockers = [],
|
||||
Warnings = warnings.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public interface IBuildGate
|
||||
{
|
||||
string GateName { get; }
|
||||
int Priority { get; }
|
||||
Task<BuildGateResult> EvaluateAsync(BuildGateContext context, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record BuildGateContext
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required string Environment { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
public string? PipelineId { get; init; }
|
||||
public Guid? TenantId { get; init; }
|
||||
public DateTimeOffset EvaluatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public bool ReachGraphChanged { get; init; }
|
||||
public decimal? EpssScore { get; init; }
|
||||
public decimal? CvssScore { get; init; }
|
||||
public int? UnknownsCount { get; init; }
|
||||
public bool NewCveInPackage { get; init; }
|
||||
public bool KevFlagged { get; init; }
|
||||
public bool VexStatusChanged { get; init; }
|
||||
public bool PackageVersionChanged { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BuildGateResult
|
||||
{
|
||||
public required bool Passed { get; init; }
|
||||
public required string GateName { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public required ImmutableArray<string> Blockers { get; init; }
|
||||
public required ImmutableArray<string> Warnings { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Caching;
|
||||
@@ -93,7 +94,8 @@ public sealed record PolicyEvaluationCacheEntry(
|
||||
string? ExceptionId,
|
||||
string CorrelationId,
|
||||
DateTimeOffset EvaluatedAt,
|
||||
DateTimeOffset ExpiresAt);
|
||||
DateTimeOffset ExpiresAt,
|
||||
ConfidenceScore? Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a cache lookup.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Http;
|
||||
using StellaOps.Policy.Confidence.Configuration;
|
||||
using StellaOps.Policy.Confidence.Services;
|
||||
using StellaOps.Policy.Engine.Attestation;
|
||||
using StellaOps.Policy.Engine.BuildGate;
|
||||
using StellaOps.Policy.Engine.Caching;
|
||||
using StellaOps.Policy.Engine.EffectiveDecisionMap;
|
||||
using StellaOps.Policy.Engine.Events;
|
||||
@@ -13,6 +16,8 @@ using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Vex;
|
||||
using StellaOps.Policy.Engine.WhatIfSimulation;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Unknowns.Configuration;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Policy.Engine.DependencyInjection;
|
||||
@@ -33,6 +38,13 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
|
||||
// Core compilation and evaluation services
|
||||
services.TryAddSingleton<PolicyCompilationService>();
|
||||
services.TryAddSingleton<PolicyEvaluator>();
|
||||
services.AddOptions<ConfidenceWeightOptions>()
|
||||
.BindConfiguration(ConfidenceWeightOptions.SectionName);
|
||||
services.TryAddSingleton<IConfidenceCalculator, ConfidenceCalculator>();
|
||||
services.AddOptions<UnknownBudgetOptions>()
|
||||
.BindConfiguration(UnknownBudgetOptions.SectionName);
|
||||
services.TryAddSingleton<IUnknownBudgetService, UnknownBudgetService>();
|
||||
|
||||
// Cache - uses IDistributedCacheFactory for transport flexibility
|
||||
services.TryAddSingleton<IPolicyEvaluationCache, MessagingPolicyEvaluationCache>();
|
||||
@@ -201,6 +213,15 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
return services.AddPolicyDecisionAttestation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds build gate evaluators for exception recheck policies.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExceptionRecheckGate(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton<IBuildGate, ExceptionRecheckGate>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Redis connection for effective decision map and evaluation cache.
|
||||
/// </summary>
|
||||
@@ -340,4 +361,4 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ internal static class BatchEvaluationEndpoint
|
||||
response.Annotations,
|
||||
response.Warnings,
|
||||
response.AppliedException,
|
||||
response.Confidence,
|
||||
response.CorrelationId,
|
||||
response.Cached,
|
||||
response.CacheSource,
|
||||
|
||||
@@ -54,6 +54,7 @@ internal static class UnknownsEndpoints
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] int offset = 0,
|
||||
IUnknownsRepository repository = null!,
|
||||
IRemediationHintsRegistry hintsRegistry = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
@@ -76,18 +77,9 @@ internal static class UnknownsEndpoints
|
||||
unknowns = hot.Concat(warm).Concat(cold).Take(limit).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
var items = unknowns.Select(u => new UnknownDto(
|
||||
u.Id,
|
||||
u.PackageId,
|
||||
u.PackageVersion,
|
||||
u.Band.ToString().ToLowerInvariant(),
|
||||
u.Score,
|
||||
u.UncertaintyFactor,
|
||||
u.ExploitPressure,
|
||||
u.FirstSeenAt,
|
||||
u.LastEvaluatedAt,
|
||||
u.ResolutionReason,
|
||||
u.ResolvedAt)).ToList();
|
||||
var items = unknowns
|
||||
.Select(u => ToDto(u, hintsRegistry))
|
||||
.ToList();
|
||||
|
||||
return TypedResults.Ok(new UnknownsListResponse(items, items.Count));
|
||||
}
|
||||
@@ -115,6 +107,7 @@ internal static class UnknownsEndpoints
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
IUnknownsRepository repository = null!,
|
||||
IRemediationHintsRegistry hintsRegistry = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
@@ -126,7 +119,7 @@ internal static class UnknownsEndpoints
|
||||
if (unknown is null)
|
||||
return TypedResults.Problem($"Unknown with ID {id} not found.", statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown)));
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown, hintsRegistry)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> Escalate(
|
||||
@@ -135,6 +128,7 @@ internal static class UnknownsEndpoints
|
||||
[FromBody] EscalateUnknownRequest request,
|
||||
IUnknownsRepository repository = null!,
|
||||
IUnknownRanker ranker = null!,
|
||||
IRemediationHintsRegistry hintsRegistry = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
@@ -164,7 +158,7 @@ internal static class UnknownsEndpoints
|
||||
// TODO: T6 - Trigger rescan job via Scheduler integration
|
||||
// await scheduler.CreateRescanJobAsync(unknown.PackageId, unknown.PackageVersion, ct);
|
||||
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown)));
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown, hintsRegistry)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> Resolve(
|
||||
@@ -172,6 +166,7 @@ internal static class UnknownsEndpoints
|
||||
Guid id,
|
||||
[FromBody] ResolveUnknownRequest request,
|
||||
IUnknownsRepository repository = null!,
|
||||
IRemediationHintsRegistry hintsRegistry = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
@@ -188,7 +183,7 @@ internal static class UnknownsEndpoints
|
||||
|
||||
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
||||
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown!)));
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown!, hintsRegistry)));
|
||||
}
|
||||
|
||||
private static Guid ResolveTenantId(HttpContext context)
|
||||
@@ -211,18 +206,42 @@ internal static class UnknownsEndpoints
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
private static UnknownDto ToDto(Unknown u) => new(
|
||||
u.Id,
|
||||
u.PackageId,
|
||||
u.PackageVersion,
|
||||
u.Band.ToString().ToLowerInvariant(),
|
||||
u.Score,
|
||||
u.UncertaintyFactor,
|
||||
u.ExploitPressure,
|
||||
u.FirstSeenAt,
|
||||
u.LastEvaluatedAt,
|
||||
u.ResolutionReason,
|
||||
u.ResolvedAt);
|
||||
private static UnknownDto ToDto(Unknown u, IRemediationHintsRegistry hintsRegistry)
|
||||
{
|
||||
var hint = hintsRegistry.GetHint(u.ReasonCode);
|
||||
var shortCode = ShortCodes.TryGetValue(u.ReasonCode, out var code) ? code : "U-RCH";
|
||||
|
||||
return new UnknownDto(
|
||||
u.Id,
|
||||
u.PackageId,
|
||||
u.PackageVersion,
|
||||
u.Band.ToString().ToLowerInvariant(),
|
||||
u.Score,
|
||||
u.UncertaintyFactor,
|
||||
u.ExploitPressure,
|
||||
u.FirstSeenAt,
|
||||
u.LastEvaluatedAt,
|
||||
u.ResolutionReason,
|
||||
u.ResolvedAt,
|
||||
u.ReasonCode.ToString(),
|
||||
shortCode,
|
||||
u.RemediationHint ?? hint.ShortHint,
|
||||
hint.DetailedHint,
|
||||
hint.AutomationRef,
|
||||
u.EvidenceRefs.Select(e => new EvidenceRefDto(e.Type, e.Uri, e.Digest)).ToList());
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<UnknownReasonCode, string> ShortCodes =
|
||||
new Dictionary<UnknownReasonCode, string>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = "U-RCH",
|
||||
[UnknownReasonCode.Identity] = "U-ID",
|
||||
[UnknownReasonCode.Provenance] = "U-PROV",
|
||||
[UnknownReasonCode.VexConflict] = "U-VEX",
|
||||
[UnknownReasonCode.FeedGap] = "U-FEED",
|
||||
[UnknownReasonCode.ConfigUnknown] = "U-CONFIG",
|
||||
[UnknownReasonCode.AnalyzerLimit] = "U-ANALYZER"
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
@@ -239,7 +258,18 @@ public sealed record UnknownDto(
|
||||
DateTimeOffset FirstSeenAt,
|
||||
DateTimeOffset LastEvaluatedAt,
|
||||
string? ResolutionReason,
|
||||
DateTimeOffset? ResolvedAt);
|
||||
DateTimeOffset? ResolvedAt,
|
||||
string ReasonCode,
|
||||
string ReasonCodeShort,
|
||||
string? RemediationHint,
|
||||
string? DetailedHint,
|
||||
string? AutomationCommand,
|
||||
IReadOnlyList<EvidenceRefDto> EvidenceRefs);
|
||||
|
||||
public sealed record EvidenceRefDto(
|
||||
string Type,
|
||||
string Uri,
|
||||
string? Digest);
|
||||
|
||||
/// <summary>Response containing a list of unknowns.</summary>
|
||||
public sealed record UnknownsListResponse(IReadOnlyList<UnknownDto> Items, int TotalCount);
|
||||
|
||||
@@ -3,6 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
@@ -18,9 +21,13 @@ internal sealed record PolicyEvaluationContext(
|
||||
PolicyEvaluationVexEvidence Vex,
|
||||
PolicyEvaluationSbom Sbom,
|
||||
PolicyEvaluationExceptions Exceptions,
|
||||
ImmutableArray<Unknown> Unknowns,
|
||||
ImmutableArray<ExceptionObject> ExceptionObjects,
|
||||
PolicyEvaluationReachability Reachability,
|
||||
PolicyEvaluationEntropy Entropy,
|
||||
DateTimeOffset? EvaluationTimestamp = null)
|
||||
DateTimeOffset? EvaluationTimestamp = null,
|
||||
string? PolicyDigest = null,
|
||||
bool? ProvenanceAttested = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evaluation timestamp for deterministic time-based operations.
|
||||
@@ -39,8 +46,25 @@ internal sealed record PolicyEvaluationContext(
|
||||
PolicyEvaluationVexEvidence vex,
|
||||
PolicyEvaluationSbom sbom,
|
||||
PolicyEvaluationExceptions exceptions,
|
||||
DateTimeOffset? evaluationTimestamp = null)
|
||||
: this(severity, environment, advisory, vex, sbom, exceptions, PolicyEvaluationReachability.Unknown, PolicyEvaluationEntropy.Unknown, evaluationTimestamp)
|
||||
ImmutableArray<Unknown>? unknowns = null,
|
||||
ImmutableArray<ExceptionObject>? exceptionObjects = null,
|
||||
DateTimeOffset? evaluationTimestamp = null,
|
||||
string? policyDigest = null,
|
||||
bool? provenanceAttested = null)
|
||||
: this(
|
||||
severity,
|
||||
environment,
|
||||
advisory,
|
||||
vex,
|
||||
sbom,
|
||||
exceptions,
|
||||
unknowns ?? ImmutableArray<Unknown>.Empty,
|
||||
exceptionObjects ?? ImmutableArray<ExceptionObject>.Empty,
|
||||
PolicyEvaluationReachability.Unknown,
|
||||
PolicyEvaluationEntropy.Unknown,
|
||||
evaluationTimestamp,
|
||||
policyDigest,
|
||||
provenanceAttested)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -100,7 +124,11 @@ internal sealed record PolicyEvaluationResult(
|
||||
int? Priority,
|
||||
ImmutableDictionary<string, string> Annotations,
|
||||
ImmutableArray<string> Warnings,
|
||||
PolicyExceptionApplication? AppliedException)
|
||||
PolicyExceptionApplication? AppliedException,
|
||||
ConfidenceScore? Confidence,
|
||||
PolicyFailureReason? FailureReason = null,
|
||||
string? FailureMessage = null,
|
||||
BudgetStatusSummary? UnknownBudgetStatus = null)
|
||||
{
|
||||
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
|
||||
Matched: false,
|
||||
@@ -110,7 +138,13 @@ internal sealed record PolicyEvaluationResult(
|
||||
Priority: null,
|
||||
Annotations: ImmutableDictionary<string, string>.Empty,
|
||||
Warnings: ImmutableArray<string>.Empty,
|
||||
AppliedException: null);
|
||||
AppliedException: null,
|
||||
Confidence: null);
|
||||
}
|
||||
|
||||
internal enum PolicyFailureReason
|
||||
{
|
||||
UnknownBudgetExceeded
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationExceptions(
|
||||
|
||||
@@ -3,7 +3,15 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Confidence.Configuration;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Confidence.Services;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
@@ -13,6 +21,19 @@ namespace StellaOps.Policy.Engine.Evaluation;
|
||||
/// </summary>
|
||||
internal sealed class PolicyEvaluator
|
||||
{
|
||||
private readonly IConfidenceCalculator _confidenceCalculator;
|
||||
private readonly IUnknownBudgetService? _budgetService;
|
||||
|
||||
public PolicyEvaluator(
|
||||
IConfidenceCalculator? confidenceCalculator = null,
|
||||
IUnknownBudgetService? budgetService = null)
|
||||
{
|
||||
_confidenceCalculator = confidenceCalculator
|
||||
?? new ConfidenceCalculator(
|
||||
new StaticOptionsMonitor<ConfidenceWeightOptions>(new ConfidenceWeightOptions()));
|
||||
_budgetService = budgetService;
|
||||
}
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyEvaluationRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
@@ -59,13 +80,18 @@ internal sealed class PolicyEvaluator
|
||||
Priority: rule.Priority,
|
||||
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
Warnings: runtime.Warnings.ToImmutableArray(),
|
||||
AppliedException: null);
|
||||
AppliedException: null,
|
||||
Confidence: null);
|
||||
|
||||
return ApplyExceptions(request, baseResult);
|
||||
var result = ApplyExceptions(request, baseResult);
|
||||
var budgeted = ApplyUnknownBudget(request.Context, result);
|
||||
return ApplyConfidence(request.Context, budgeted);
|
||||
}
|
||||
|
||||
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
|
||||
return ApplyExceptions(request, defaultResult);
|
||||
var defaultWithExceptions = ApplyExceptions(request, defaultResult);
|
||||
var budgetedDefault = ApplyUnknownBudget(request.Context, defaultWithExceptions);
|
||||
return ApplyConfidence(request.Context, budgetedDefault);
|
||||
}
|
||||
|
||||
private static void ApplyAction(
|
||||
@@ -417,4 +443,314 @@ internal sealed class PolicyEvaluator
|
||||
AppliedException = application,
|
||||
};
|
||||
}
|
||||
|
||||
private PolicyEvaluationResult ApplyUnknownBudget(PolicyEvaluationContext context, PolicyEvaluationResult baseResult)
|
||||
{
|
||||
if (_budgetService is null || context.Unknowns.IsDefaultOrEmpty)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
var environment = ResolveEnvironmentName(context.Environment);
|
||||
var budgetResult = _budgetService.CheckBudgetWithEscalation(
|
||||
environment,
|
||||
context.Unknowns,
|
||||
context.ExceptionObjects);
|
||||
var status = _budgetService.GetBudgetStatus(environment, context.Unknowns);
|
||||
|
||||
var annotations = baseResult.Annotations.ToBuilder();
|
||||
annotations["unknownBudget.environment"] = environment;
|
||||
annotations["unknownBudget.total"] = budgetResult.TotalUnknowns.ToString(CultureInfo.InvariantCulture);
|
||||
annotations["unknownBudget.action"] = budgetResult.RecommendedAction.ToString();
|
||||
if (budgetResult.TotalLimit.HasValue)
|
||||
{
|
||||
annotations["unknownBudget.totalLimit"] = budgetResult.TotalLimit.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
annotations["unknownBudget.exceeded"] = (!budgetResult.IsWithinBudget).ToString();
|
||||
if (!string.IsNullOrWhiteSpace(budgetResult.Message))
|
||||
{
|
||||
annotations["unknownBudget.message"] = budgetResult.Message!;
|
||||
}
|
||||
|
||||
var warnings = baseResult.Warnings;
|
||||
if (!budgetResult.IsWithinBudget
|
||||
&& budgetResult.RecommendedAction is BudgetAction.Warn or BudgetAction.WarnUnlessException
|
||||
&& !string.IsNullOrWhiteSpace(budgetResult.Message))
|
||||
{
|
||||
warnings = warnings.Add(budgetResult.Message!);
|
||||
}
|
||||
|
||||
var result = baseResult with
|
||||
{
|
||||
Annotations = annotations.ToImmutable(),
|
||||
Warnings = warnings,
|
||||
UnknownBudgetStatus = status
|
||||
};
|
||||
|
||||
if (_budgetService.ShouldBlock(budgetResult))
|
||||
{
|
||||
result = result with
|
||||
{
|
||||
Status = "blocked",
|
||||
FailureReason = PolicyFailureReason.UnknownBudgetExceeded,
|
||||
FailureMessage = budgetResult.Message ?? "Unknown budget exceeded"
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ResolveEnvironmentName(PolicyEvaluationEnvironment environment)
|
||||
{
|
||||
var name = environment.Get("name") ?? environment.Get("environment") ?? environment.Get("env");
|
||||
return string.IsNullOrWhiteSpace(name) ? "default" : name.Trim();
|
||||
}
|
||||
|
||||
private PolicyEvaluationResult ApplyConfidence(PolicyEvaluationContext context, PolicyEvaluationResult baseResult)
|
||||
{
|
||||
var input = BuildConfidenceInput(context, baseResult);
|
||||
var confidence = _confidenceCalculator.Calculate(input);
|
||||
return baseResult with { Confidence = confidence };
|
||||
}
|
||||
|
||||
private static ConfidenceInput BuildConfidenceInput(PolicyEvaluationContext context, PolicyEvaluationResult result)
|
||||
{
|
||||
return new ConfidenceInput
|
||||
{
|
||||
Reachability = BuildReachabilityEvidence(context.Reachability),
|
||||
Runtime = BuildRuntimeEvidence(context),
|
||||
Vex = BuildVexEvidence(context),
|
||||
Provenance = BuildProvenanceEvidence(context),
|
||||
Policy = BuildPolicyEvidence(context, result),
|
||||
Status = result.Status,
|
||||
EvaluationTimestamp = context.Now
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityEvidence? BuildReachabilityEvidence(PolicyEvaluationReachability reachability)
|
||||
{
|
||||
if (reachability.IsUnknown && string.IsNullOrWhiteSpace(reachability.EvidenceRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var state = reachability.IsReachable
|
||||
? (reachability.HasRuntimeEvidence ? ReachabilityState.ConfirmedReachable : ReachabilityState.StaticReachable)
|
||||
: reachability.IsUnreachable
|
||||
? (reachability.HasRuntimeEvidence ? ReachabilityState.ConfirmedUnreachable : ReachabilityState.StaticUnreachable)
|
||||
: ReachabilityState.Unknown;
|
||||
|
||||
var digests = string.IsNullOrWhiteSpace(reachability.EvidenceRef)
|
||||
? Array.Empty<string>()
|
||||
: new[] { reachability.EvidenceRef! };
|
||||
|
||||
return new ReachabilityEvidence
|
||||
{
|
||||
State = state,
|
||||
AnalysisConfidence = Clamp01(reachability.Confidence),
|
||||
GraphDigests = digests
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeEvidence? BuildRuntimeEvidence(PolicyEvaluationContext context)
|
||||
{
|
||||
if (!context.Reachability.HasRuntimeEvidence)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var posture = context.Reachability.IsReachable || context.Reachability.IsUnreachable
|
||||
? RuntimePosture.Supports
|
||||
: RuntimePosture.Unknown;
|
||||
|
||||
return new RuntimeEvidence
|
||||
{
|
||||
Posture = posture,
|
||||
ObservationCount = 1,
|
||||
LastObserved = context.Now,
|
||||
SessionDigests = Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static VexEvidence? BuildVexEvidence(PolicyEvaluationContext context)
|
||||
{
|
||||
if (context.Vex.Statements.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var issuer = string.IsNullOrWhiteSpace(context.Advisory.Source)
|
||||
? "unknown"
|
||||
: context.Advisory.Source;
|
||||
|
||||
var statements = context.Vex.Statements
|
||||
.Select(statement =>
|
||||
{
|
||||
var timestamp = statement.Timestamp ?? DateTimeOffset.MinValue;
|
||||
return new VexStatement
|
||||
{
|
||||
Status = MapVexStatus(statement.Status),
|
||||
Issuer = issuer,
|
||||
TrustScore = ComputeVexTrustScore(issuer, statement),
|
||||
Timestamp = timestamp,
|
||||
StatementDigest = ComputeVexDigest(issuer, statement, timestamp)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new VexEvidence { Statements = statements };
|
||||
}
|
||||
|
||||
private static ProvenanceEvidence? BuildProvenanceEvidence(PolicyEvaluationContext context)
|
||||
{
|
||||
var hasSbomComponents = !context.Sbom.Components.IsDefaultOrEmpty;
|
||||
if (context.ProvenanceAttested is null && !hasSbomComponents)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var level = context.ProvenanceAttested == true ? ProvenanceLevel.Signed : ProvenanceLevel.Unsigned;
|
||||
|
||||
return new ProvenanceEvidence
|
||||
{
|
||||
Level = level,
|
||||
SbomCompleteness = ComputeSbomCompleteness(context.Sbom),
|
||||
AttestationDigests = Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyEvidence BuildPolicyEvidence(PolicyEvaluationContext context, PolicyEvaluationResult result)
|
||||
{
|
||||
var ruleName = result.RuleName ?? "default";
|
||||
var matchStrength = result.Matched ? 0.9m : 0.6m;
|
||||
|
||||
return new PolicyEvidence
|
||||
{
|
||||
RuleName = ruleName,
|
||||
MatchStrength = Clamp01(matchStrength),
|
||||
EvaluationDigest = ComputePolicyEvaluationDigest(context.PolicyDigest, result)
|
||||
};
|
||||
}
|
||||
|
||||
private static VexStatus MapVexStatus(string status)
|
||||
{
|
||||
if (status.Equals("not_affected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VexStatus.NotAffected;
|
||||
}
|
||||
|
||||
if (status.Equals("fixed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VexStatus.Fixed;
|
||||
}
|
||||
|
||||
if (status.Equals("under_investigation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VexStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
if (status.Equals("affected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return VexStatus.Affected;
|
||||
}
|
||||
|
||||
return VexStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
private static decimal ComputeVexTrustScore(string issuer, PolicyEvaluationVexStatement statement)
|
||||
{
|
||||
var score = issuer.Contains("vendor", StringComparison.OrdinalIgnoreCase)
|
||||
|| issuer.Contains("distro", StringComparison.OrdinalIgnoreCase)
|
||||
? 0.85m
|
||||
: 0.7m;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statement.Justification))
|
||||
{
|
||||
score += 0.05m;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statement.StatementId))
|
||||
{
|
||||
score += 0.05m;
|
||||
}
|
||||
|
||||
return Clamp01(score);
|
||||
}
|
||||
|
||||
private static string ComputeVexDigest(
|
||||
string issuer,
|
||||
PolicyEvaluationVexStatement statement,
|
||||
DateTimeOffset timestamp)
|
||||
{
|
||||
var input = $"{issuer}|{statement.Status}|{statement.Justification}|{statement.StatementId}|{timestamp:O}";
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static decimal ComputeSbomCompleteness(PolicyEvaluationSbom sbom)
|
||||
{
|
||||
if (sbom.Components.IsDefaultOrEmpty)
|
||||
{
|
||||
return 0.4m;
|
||||
}
|
||||
|
||||
var count = sbom.Components.Length;
|
||||
return count switch
|
||||
{
|
||||
<= 5 => 0.6m,
|
||||
<= 20 => 0.75m,
|
||||
<= 100 => 0.85m,
|
||||
_ => 0.9m
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputePolicyEvaluationDigest(string? policyDigest, PolicyEvaluationResult result)
|
||||
{
|
||||
var input = string.Join(
|
||||
'|',
|
||||
policyDigest ?? "unknown",
|
||||
result.RuleName ?? "default",
|
||||
result.Status,
|
||||
result.Severity ?? "none",
|
||||
result.Priority?.ToString(CultureInfo.InvariantCulture) ?? "none");
|
||||
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(input), hash);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static decimal Clamp01(decimal value)
|
||||
{
|
||||
if (value <= 0m)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
if (value >= 1m)
|
||||
{
|
||||
return 1m;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,20 @@ namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed partial class PolicyEvaluationService
|
||||
{
|
||||
private readonly PolicyEvaluator evaluator = new();
|
||||
private readonly PolicyEvaluator _evaluator;
|
||||
private readonly PathScopeMetrics _pathMetrics;
|
||||
private readonly ILogger<PolicyEvaluationService> _logger;
|
||||
|
||||
public PolicyEvaluationService()
|
||||
: this(new PathScopeMetrics(), NullLogger<PolicyEvaluationService>.Instance)
|
||||
: this(new PathScopeMetrics(), NullLogger<PolicyEvaluationService>.Instance, new PolicyEvaluator())
|
||||
{
|
||||
}
|
||||
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics, ILogger<PolicyEvaluationService> logger)
|
||||
public PolicyEvaluationService(PathScopeMetrics pathMetrics, ILogger<PolicyEvaluationService> logger, PolicyEvaluator evaluator)
|
||||
{
|
||||
_pathMetrics = pathMetrics ?? throw new ArgumentNullException(nameof(pathMetrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
|
||||
}
|
||||
|
||||
internal Evaluation.PolicyEvaluationResult Evaluate(PolicyIrDocument document, Evaluation.PolicyEvaluationContext context)
|
||||
@@ -36,7 +37,7 @@ internal sealed partial class PolicyEvaluationService
|
||||
}
|
||||
|
||||
var request = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
return evaluator.Evaluate(request);
|
||||
return _evaluator.Evaluate(request);
|
||||
}
|
||||
|
||||
// PathScopeSimulationService partial class relies on _pathMetrics.
|
||||
|
||||
@@ -5,10 +5,13 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Caching;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
@@ -48,6 +51,7 @@ internal sealed record RuntimeEvaluationResponse(
|
||||
ImmutableDictionary<string, string> Annotations,
|
||||
ImmutableArray<string> Warnings,
|
||||
PolicyExceptionApplication? AppliedException,
|
||||
ConfidenceScore? Confidence,
|
||||
string CorrelationId,
|
||||
bool Cached,
|
||||
CacheSource CacheSource,
|
||||
@@ -174,9 +178,13 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
effectiveRequest.Vex,
|
||||
effectiveRequest.Sbom,
|
||||
effectiveRequest.Exceptions,
|
||||
ImmutableArray<Unknown>.Empty,
|
||||
ImmutableArray<ExceptionObject>.Empty,
|
||||
effectiveRequest.Reachability,
|
||||
entropy,
|
||||
evaluationTimestamp);
|
||||
evaluationTimestamp,
|
||||
policyDigest: bundle.Digest,
|
||||
provenanceAttested: effectiveRequest.ProvenanceAttested);
|
||||
|
||||
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
var result = _evaluator.Evaluate(evalRequest);
|
||||
@@ -195,7 +203,8 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
result.AppliedException?.ExceptionId,
|
||||
correlationId,
|
||||
evaluationTimestamp,
|
||||
expiresAt);
|
||||
expiresAt,
|
||||
result.Confidence);
|
||||
|
||||
await _cache.SetAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -244,6 +253,7 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
result.Annotations,
|
||||
result.Warnings,
|
||||
result.AppliedException,
|
||||
result.Confidence,
|
||||
correlationId,
|
||||
Cached: false,
|
||||
CacheSource: CacheSource.None,
|
||||
@@ -354,9 +364,13 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
request.Vex,
|
||||
request.Sbom,
|
||||
request.Exceptions,
|
||||
ImmutableArray<Unknown>.Empty,
|
||||
ImmutableArray<ExceptionObject>.Empty,
|
||||
request.Reachability,
|
||||
entropy,
|
||||
evaluationTimestamp);
|
||||
evaluationTimestamp,
|
||||
policyDigest: bundle.Digest,
|
||||
provenanceAttested: request.ProvenanceAttested);
|
||||
|
||||
var evalRequest = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
var result = _evaluator.Evaluate(evalRequest);
|
||||
@@ -375,7 +389,8 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
result.AppliedException?.ExceptionId,
|
||||
correlationId,
|
||||
evaluationTimestamp,
|
||||
expiresAt);
|
||||
expiresAt,
|
||||
result.Confidence);
|
||||
|
||||
entriesToCache[key] = cacheEntry;
|
||||
cacheMisses++;
|
||||
@@ -413,6 +428,7 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
result.Annotations,
|
||||
result.Warnings,
|
||||
result.AppliedException,
|
||||
result.Confidence,
|
||||
correlationId,
|
||||
Cached: false,
|
||||
CacheSource: CacheSource.None,
|
||||
@@ -473,6 +489,7 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
entry.Annotations,
|
||||
entry.Warnings,
|
||||
appliedException,
|
||||
entry.Confidence,
|
||||
entry.CorrelationId,
|
||||
Cached: true,
|
||||
CacheSource: source,
|
||||
@@ -496,8 +513,12 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
severityScore = request.Severity.Score,
|
||||
advisorySource = request.Advisory.Source,
|
||||
vexCount = request.Vex.Statements.Length,
|
||||
vexStatements = request.Vex.Statements.Select(s => $"{s.Status}:{s.Justification}").OrderBy(s => s).ToArray(),
|
||||
vexStatements = request.Vex.Statements
|
||||
.Select(s => $"{s.Status}:{s.Justification}:{s.StatementId}:{s.Timestamp:O}")
|
||||
.OrderBy(s => s)
|
||||
.ToArray(),
|
||||
sbomTags = request.Sbom.Tags.OrderBy(t => t).ToArray(),
|
||||
sbomComponentCount = request.Sbom.Components.IsDefaultOrEmpty ? 0 : request.Sbom.Components.Length,
|
||||
exceptionCount = request.Exceptions.Instances.Length,
|
||||
reachability = new
|
||||
{
|
||||
@@ -506,7 +527,8 @@ internal sealed class PolicyRuntimeEvaluationService
|
||||
score = request.Reachability.Score,
|
||||
hasRuntimeEvidence = request.Reachability.HasRuntimeEvidence,
|
||||
source = request.Reachability.Source,
|
||||
method = request.Reachability.Method
|
||||
method = request.Reachability.Method,
|
||||
evidenceRef = request.Reachability.EvidenceRef
|
||||
},
|
||||
entropy = new
|
||||
{
|
||||
|
||||
@@ -7,3 +7,6 @@ This file mirrors sprint work for the Policy Engine module.
|
||||
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented PolicyGateEvaluator (lattice/uncertainty/evidence completeness) and aligned tests/docs; see `src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs` and `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Gates/PolicyGateEvaluatorTests.cs`. |
|
||||
| `DET-3401-011` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `Explain` to `RiskScoringResult` and covered JSON serialization + null-coercion in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Scoring/RiskScoringResultTests.cs`. |
|
||||
| `PDA-3801-0001` | `docs/implplan/SPRINT_3801_0001_0001_policy_decision_attestation.md` | DONE (2025-12-19) | Implemented `PolicyDecisionAttestationService` + predicate model + DI wiring; covered signer/Rekor flows in `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Attestation/PolicyDecisionAttestationServiceTests.cs`. |
|
||||
| `EXC-3900-0003-0002-T6` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added ExceptionRecheckGate and DI registration for build gate integration. |
|
||||
| `UNK-4100-0001-T6` | `docs/implplan/SPRINT_4100_0001_0001_reason_coded_unknowns.md` | DONE (2025-12-22) | Extended unknowns API DTOs with reason codes, remediation hints, and evidence refs. |
|
||||
| `UNK-4100-0001-0002` | `docs/implplan/SPRINT_4100_0001_0002_unknown_budgets.md` | DONE (2025-12-22) | Added unknown budget enforcement in policy evaluation, options binding, and budget service tests. |
|
||||
|
||||
Reference in New Issue
Block a user