Merge remote changes (theirs)

This commit is contained in:
Codex Assistant
2026-01-08 09:01:53 +02:00
4195 changed files with 249446 additions and 83444 deletions

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -264,7 +265,7 @@ internal sealed class ExceptionAdapter : IExceptionAdapter
builder["exception.owner"] = exception.OwnerId;
builder["exception.requester"] = exception.RequesterId;
builder["exception.rationale"] = exception.Rationale;
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O");
builder["exception.expiresAt"] = exception.ExpiresAt.ToString("O", CultureInfo.InvariantCulture);
if (exception.ApproverIds.Length > 0)
{

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.AirGap;
@@ -393,7 +394,7 @@ internal sealed class WebhookNotificationChannel : IAirGapNotificationChannel
severity = notification.Severity.ToString(),
title = notification.Title,
message = notification.Message,
occurred_at = notification.OccurredAt.ToString("O"),
occurred_at = notification.OccurredAt.ToString("O", CultureInfo.InvariantCulture),
metadata = notification.Metadata
};

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -68,7 +69,7 @@ internal sealed class PolicyPackBundleImportService
TenantId: tenantId,
Status: BundleImportStatus.Validating,
ExportCount: 0,
ImportedAt: now.ToString("O"),
ImportedAt: now.ToString("O", CultureInfo.InvariantCulture),
Error: null,
Bundle: null);
@@ -163,7 +164,7 @@ internal sealed class PolicyPackBundleImportService
TenantId: tenantId,
Status: BundleImportStatus.Imported,
ExportCount: bundle.Exports.Count,
ImportedAt: now.ToString("O"),
ImportedAt: now.ToString("O", CultureInfo.InvariantCulture),
Error: null,
Bundle: bundle);

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -76,7 +77,7 @@ public sealed class RiskProfileAirGapExportService
ExportId: Guid.NewGuid().ToString("N")[..16],
ProfileId: profile.Id,
ProfileVersion: profile.Version,
CreatedAt: now.ToString("O"),
CreatedAt: now.ToString("O", CultureInfo.InvariantCulture),
ArtifactSizeBytes: Encoding.UTF8.GetByteCount(profileJson),
ArtifactDigest: artifactDigest,
ContentHash: contentHash,
@@ -99,7 +100,7 @@ public sealed class RiskProfileAirGapExportService
return new RiskProfileAirGapBundle(
SchemaVersion: 1,
GeneratedAt: now.ToString("O"),
GeneratedAt: now.ToString("O", CultureInfo.InvariantCulture),
TargetRepository: request.TargetRepository,
DomainId: DomainId,
DisplayName: request.DisplayName ?? "Risk Profiles Export",
@@ -337,7 +338,7 @@ public sealed class RiskProfileAirGapExportService
Algorithm: "HMAC-SHA256",
KeyId: keyId ?? "default",
Provider: "stellaops",
SignedAt: signedAt.ToString("O"));
SignedAt: signedAt.ToString("O", CultureInfo.InvariantCulture));
}
private static string ComputeSignatureData(List<RiskProfileAirGapExport> exports, string merkleRoot)
@@ -422,7 +423,7 @@ public sealed class RiskProfileAirGapExportService
PredicateType: PredicateType,
RekorLocation: null,
EnvelopeDigest: null,
SignedAt: signedAt.ToString("O"));
SignedAt: signedAt.ToString("O", CultureInfo.InvariantCulture));
}
private static string GenerateBundleId(DateTimeOffset timestamp)

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -129,7 +130,7 @@ public sealed record ScoreProvenanceChain
rule_name = Verdict.MatchedRuleName,
verdict_digest = Verdict.VerdictDigest
},
created_at = CreatedAt.ToUniversalTime().ToString("O")
created_at = CreatedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
};
var json = JsonSerializer.Serialize(canonical, ProvenanceJsonOptions.Default);
@@ -662,7 +663,7 @@ public sealed record ProvenanceVerdictRef
status = predicate.Verdict.Status,
severity = predicate.Verdict.Severity,
score = predicate.Verdict.Score,
evaluated_at = predicate.EvaluatedAt.ToUniversalTime().ToString("O")
evaluated_at = predicate.EvaluatedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
};
var json = JsonSerializer.Serialize(canonical, ProvenanceJsonOptions.Default);

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -38,7 +39,7 @@ internal sealed class BatchContextService
ComputeTraceRef(request.TenantId, i)))
.ToList();
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O");
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O", CultureInfo.InvariantCulture);
var contextId = ComputeContextId(request, sortedItems);
return new BatchContextResponse(

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -58,7 +59,7 @@ internal sealed partial class ConsoleExportJobService
Destination: request.Destination,
Signing: request.Signing,
Enabled: true,
CreatedAt: now.ToString("O"),
CreatedAt: now.ToString("O", CultureInfo.InvariantCulture),
LastRunAt: null,
NextRunAt: CalculateNextRun(request.Schedule, now));
@@ -128,7 +129,7 @@ internal sealed partial class ConsoleExportJobService
JobId: jobId,
Status: "running",
BundleId: null,
StartedAt: now.ToString("O"),
StartedAt: now.ToString("O", CultureInfo.InvariantCulture),
CompletedAt: null,
Error: null);
@@ -177,7 +178,7 @@ internal sealed partial class ConsoleExportJobService
BundleId: bundleId,
JobId: job.JobId,
TenantId: job.TenantId,
CreatedAt: now.ToString("O"),
CreatedAt: now.ToString("O", CultureInfo.InvariantCulture),
Format: job.Format,
ArtifactDigest: artifactDigest,
ArtifactSizeBytes: contentBytes.Length,
@@ -195,14 +196,14 @@ internal sealed partial class ConsoleExportJobService
{
Status = "completed",
BundleId = bundleId,
CompletedAt = now.ToString("O")
CompletedAt = now.ToString("O", CultureInfo.InvariantCulture)
};
await _executionStore.SaveAsync(completedExecution, cancellationToken).ConfigureAwait(false);
// Update job with last run
var updatedJob = job with
{
LastRunAt = now.ToString("O"),
LastRunAt = now.ToString("O", CultureInfo.InvariantCulture),
NextRunAt = CalculateNextRun(job.Schedule, now)
};
await _jobStore.SaveAsync(updatedJob, cancellationToken).ConfigureAwait(false);
@@ -212,7 +213,7 @@ internal sealed partial class ConsoleExportJobService
var failedExecution = execution with
{
Status = "failed",
CompletedAt = _timeProvider.GetUtcNow().ToString("O"),
CompletedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
Error = ex.Message
};
await _executionStore.SaveAsync(failedExecution, CancellationToken.None).ConfigureAwait(false);
@@ -269,7 +270,7 @@ internal sealed partial class ConsoleExportJobService
// In production, this would use a proper cron parser like Cronos
if (schedule.StartsWith("0 0 ", StringComparison.Ordinal))
{
return from.AddDays(1).ToString("O");
return from.AddDays(1).ToString("O", CultureInfo.InvariantCulture);
}
if (schedule.StartsWith("0 */", StringComparison.Ordinal))
@@ -277,11 +278,11 @@ internal sealed partial class ConsoleExportJobService
var hourMatch = Regex.Match(schedule, @"\*/(\d+)");
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out var hours))
{
return from.AddHours(hours).ToString("O");
return from.AddHours(hours).ToString("O", CultureInfo.InvariantCulture);
}
}
return from.AddDays(1).ToString("O");
return from.AddDays(1).ToString("O", CultureInfo.InvariantCulture);
}
private static string GenerateId(string prefix)

View File

@@ -0,0 +1,41 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Gates.Determinization;
using StellaOps.Policy.Engine.Policies;
using StellaOps.Policy.Engine.Subscriptions;
namespace StellaOps.Policy.Engine.DependencyInjection;
/// <summary>
/// Dependency injection extensions for determinization engine.
/// </summary>
public static class DeterminizationEngineExtensions
{
/// <summary>
/// Add determinization gate and related services to the service collection.
/// </summary>
public static IServiceCollection AddDeterminizationEngine(this IServiceCollection services)
{
// Add determinization library services
services.AddDeterminization();
// Add metrics
services.TryAddSingleton<DeterminizationGateMetrics>();
// Add gate
services.TryAddSingleton<IDeterminizationGate, DeterminizationGate>();
// Add policy
services.TryAddSingleton<IDeterminizationPolicy, DeterminizationPolicy>();
// Add signal snapshot builder
services.TryAddSingleton<ISignalSnapshotBuilder, SignalSnapshotBuilder>();
// Add signal update subscription
services.TryAddSingleton<ISignalUpdateSubscription, SignalUpdateHandler>();
return services;
}
}

View File

@@ -294,7 +294,7 @@ public static class PolicyEngineServiceCollectionExtensions
/// Adds all Policy Engine services with default configuration.
/// </summary>
/// <remarks>
/// Includes core services, event pipeline, worker, explainer, and Evidence-Weighted Score services.
/// Includes core services, event pipeline, worker, explainer, determinization gate, and Evidence-Weighted Score services.
/// EWS services are registered but only activate when <see cref="PolicyEvidenceWeightedScoreOptions.Enabled"/> is true.
/// </remarks>
public static IServiceCollection AddPolicyEngine(this IServiceCollection services)
@@ -304,6 +304,9 @@ public static class PolicyEngineServiceCollectionExtensions
services.AddPolicyEngineWorker();
services.AddPolicyEngineExplainer();
// Determinization gate and policy services (Sprint 20260106_001_003)
services.AddDeterminizationEngine();
// Evidence-Weighted Score services (Sprint 8200.0012.0003)
// Always registered; activation controlled by PolicyEvidenceWeightedScoreOptions.Enabled
services.AddEvidenceWeightedScore();
@@ -342,6 +345,9 @@ public static class PolicyEngineServiceCollectionExtensions
services.AddPolicyEngineWorker();
services.AddPolicyEngineExplainer();
// Determinization gate and policy services (Sprint 20260106_001_003)
services.AddDeterminizationEngine();
// Conditional EWS registration based on configuration
services.AddEvidenceWeightedScoreIfEnabled(configuration);

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -544,7 +545,7 @@ internal sealed class MessagingExceptionEffectiveCache : IExceptionEffectiveCach
var statsKey = GetStatsKey(tenantId);
var stats = new Dictionary<string, string>
{
["lastWarmAt"] = warmAt.ToString("O"),
["lastWarmAt"] = warmAt.ToString("O", CultureInfo.InvariantCulture),
["lastWarmCount"] = count.ToString(),
};

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -642,7 +643,7 @@ internal sealed class RedisExceptionEffectiveCache : IExceptionEffectiveCache
var stats = new Dictionary<string, string>
{
["lastWarmAt"] = warmAt.ToString("O"),
["lastWarmAt"] = warmAt.ToString("O", CultureInfo.InvariantCulture),
["lastWarmCount"] = count.ToString(),
};

View File

@@ -0,0 +1,205 @@
using System.Collections.Immutable;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using StellaOps.Policy.Engine.Gates.Determinization;
using StellaOps.Policy.Engine.Policies;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Gate that evaluates CVE observations against determinization thresholds.
/// </summary>
public sealed class DeterminizationGate : IDeterminizationGate
{
private static readonly Meter Meter = new("StellaOps.Policy.Engine.Gates");
private static readonly Counter<long> EvaluationsCounter = Meter.CreateCounter<long>(
"stellaops_policy_determinization_evaluations_total",
"evaluations",
"Total determinization gate evaluations");
private static readonly Counter<long> RuleMatchesCounter = Meter.CreateCounter<long>(
"stellaops_policy_determinization_rule_matches_total",
"matches",
"Total determinization rule matches by rule name");
private readonly IDeterminizationPolicy _policy;
private readonly IUncertaintyScoreCalculator _uncertaintyCalculator;
private readonly IDecayedConfidenceCalculator _decayCalculator;
private readonly TrustScoreAggregator _trustAggregator;
private readonly ISignalSnapshotBuilder _snapshotBuilder;
private readonly ILogger<DeterminizationGate> _logger;
public DeterminizationGate(
IDeterminizationPolicy policy,
IUncertaintyScoreCalculator uncertaintyCalculator,
IDecayedConfidenceCalculator decayCalculator,
TrustScoreAggregator trustAggregator,
ISignalSnapshotBuilder snapshotBuilder,
ILogger<DeterminizationGate> logger)
{
_policy = policy;
_uncertaintyCalculator = uncertaintyCalculator;
_decayCalculator = decayCalculator;
_trustAggregator = trustAggregator;
_snapshotBuilder = snapshotBuilder;
_logger = logger;
}
public async Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
var result = await EvaluateDeterminizationAsync(mergeResult, context, ct);
return new GateResult
{
GateName = "DeterminizationGate",
Passed = result.Passed,
Reason = result.Reason,
Details = BuildDetails(result)
};
}
public async Task<DeterminizationGateResult> EvaluateDeterminizationAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
// Extract CVE ID and PURL from context
var cveId = context.CveId ?? throw new ArgumentException("CveId is required", nameof(context));
var purl = context.SubjectKey ?? throw new ArgumentException("SubjectKey is required", nameof(context));
// 1. Build signal snapshot for the CVE/component
var snapshot = await _snapshotBuilder.BuildAsync(cveId, purl, ct);
// 2. Calculate uncertainty
var uncertainty = _uncertaintyCalculator.Calculate(snapshot);
// 3. Calculate decay
var lastUpdate = DetermineLastSignalUpdate(snapshot);
var decay = _decayCalculator.Calculate(
baseConfidence: 1.0,
ageDays: (snapshot.SnapshotAt - lastUpdate).TotalDays,
halfLifeDays: 30,
floor: 0.1);
// 4. Calculate trust score
var trustScore = _trustAggregator.Aggregate(snapshot, uncertainty);
// 5. Parse environment from context
var environment = ParseEnvironment(context.Environment);
// 6. Build determinization context
var determCtx = new DeterminizationContext
{
SignalSnapshot = snapshot,
UncertaintyScore = uncertainty,
Decay = new ObservationDecay
{
LastSignalUpdate = lastUpdate,
AgeDays = (snapshot.SnapshotAt - lastUpdate).TotalDays,
DecayedMultiplier = decay,
IsStale = decay < 0.5
},
TrustScore = trustScore,
Environment = environment
};
// 7. Evaluate policy
var policyResult = _policy.Evaluate(determCtx);
// 8. Record metrics
EvaluationsCounter.Add(1,
new KeyValuePair<string, object?>("status", policyResult.Status.ToString()),
new KeyValuePair<string, object?>("environment", environment.ToString()),
new KeyValuePair<string, object?>("passed", policyResult.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass));
if (policyResult.MatchedRule is not null)
{
RuleMatchesCounter.Add(1,
new KeyValuePair<string, object?>("rule", policyResult.MatchedRule),
new KeyValuePair<string, object?>("status", policyResult.Status.ToString()));
}
_logger.LogInformation(
"DeterminizationGate evaluated CVE {CveId} on {Purl}: status={Status}, entropy={Entropy:F3}, trust={Trust:F3}, rule={Rule}",
cveId,
purl,
policyResult.Status,
uncertainty.Entropy,
trustScore,
policyResult.MatchedRule);
return new DeterminizationGateResult
{
Passed = policyResult.Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass,
Status = policyResult.Status,
Reason = policyResult.Reason,
GuardRails = policyResult.GuardRails,
UncertaintyScore = uncertainty,
Decay = determCtx.Decay,
TrustScore = trustScore,
MatchedRule = policyResult.MatchedRule,
Metadata = null
};
}
private static DateTimeOffset DetermineLastSignalUpdate(SignalSnapshot snapshot)
{
var timestamps = new List<DateTimeOffset>();
if (snapshot.Epss.QueriedAt.HasValue) timestamps.Add(snapshot.Epss.QueriedAt.Value);
if (snapshot.Vex.QueriedAt.HasValue) timestamps.Add(snapshot.Vex.QueriedAt.Value);
if (snapshot.Reachability.QueriedAt.HasValue) timestamps.Add(snapshot.Reachability.QueriedAt.Value);
if (snapshot.Runtime.QueriedAt.HasValue) timestamps.Add(snapshot.Runtime.QueriedAt.Value);
if (snapshot.Backport.QueriedAt.HasValue) timestamps.Add(snapshot.Backport.QueriedAt.Value);
if (snapshot.Sbom.QueriedAt.HasValue) timestamps.Add(snapshot.Sbom.QueriedAt.Value);
if (snapshot.Cvss.QueriedAt.HasValue) timestamps.Add(snapshot.Cvss.QueriedAt.Value);
return timestamps.Count > 0 ? timestamps.Max() : snapshot.SnapshotAt;
}
private static DeploymentEnvironment ParseEnvironment(string environment) =>
environment.ToLowerInvariant() switch
{
"production" or "prod" => DeploymentEnvironment.Production,
"staging" or "stage" => DeploymentEnvironment.Staging,
"testing" or "test" => DeploymentEnvironment.Testing,
"development" or "dev" => DeploymentEnvironment.Development,
_ => DeploymentEnvironment.Development
};
private static ImmutableDictionary<string, object> BuildDetails(DeterminizationGateResult result)
{
var builder = ImmutableDictionary.CreateBuilder<string, object>();
builder["uncertainty_entropy"] = result.UncertaintyScore.Entropy;
builder["uncertainty_tier"] = result.UncertaintyScore.Tier.ToString();
builder["uncertainty_completeness"] = result.UncertaintyScore.Completeness;
builder["decay_multiplier"] = result.Decay.DecayedMultiplier;
builder["decay_is_stale"] = result.Decay.IsStale;
builder["decay_age_days"] = result.Decay.AgeDays;
builder["trust_score"] = result.TrustScore;
if (result.MatchedRule is not null)
builder["matched_rule"] = result.MatchedRule;
if (result.GuardRails is not null)
{
builder["guardrails_monitoring"] = result.GuardRails.EnableMonitoring;
if (result.GuardRails.ReevalAfter.HasValue)
builder["guardrails_reeval_after"] = result.GuardRails.ReevalAfter.Value.ToString();
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,163 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Gates.Determinization;
/// <summary>
/// OpenTelemetry metrics for determinization gate and observation state tracking.
/// </summary>
public sealed class DeterminizationGateMetrics : IDisposable
{
private readonly Meter _meter;
private readonly Counter<long> _evaluationsTotal;
private readonly Counter<long> _ruleMatchesTotal;
private readonly Counter<long> _stateTransitionsTotal;
private readonly Histogram<double> _entropyDistribution;
private readonly Histogram<double> _trustScoreDistribution;
private readonly Histogram<double> _evaluationDurationMs;
public const string MeterName = "StellaOps.Policy.Engine.DeterminizationGate";
public DeterminizationGateMetrics()
{
_meter = new Meter(MeterName, "1.0.0");
_evaluationsTotal = _meter.CreateCounter<long>(
"stellaops_policy_determinization_evaluations_total",
unit: "{evaluations}",
description: "Total number of determinization gate evaluations");
_ruleMatchesTotal = _meter.CreateCounter<long>(
"stellaops_policy_determinization_rule_matches_total",
unit: "{matches}",
description: "Total number of determinization rule matches by rule name");
_stateTransitionsTotal = _meter.CreateCounter<long>(
"stellaops_policy_observation_state_transitions_total",
unit: "{transitions}",
description: "Total number of observation state transitions");
_entropyDistribution = _meter.CreateHistogram<double>(
"stellaops_policy_determinization_entropy",
unit: "1",
description: "Distribution of entropy scores evaluated");
_trustScoreDistribution = _meter.CreateHistogram<double>(
"stellaops_policy_determinization_trust_score",
unit: "1",
description: "Distribution of trust scores evaluated");
_evaluationDurationMs = _meter.CreateHistogram<double>(
"stellaops_policy_determinization_evaluation_duration_ms",
unit: "ms",
description: "Duration of determinization gate evaluations");
}
/// <summary>
/// Record a gate evaluation.
/// </summary>
public void RecordEvaluation(
PolicyVerdictStatus status,
string environment,
string? matchedRule)
{
_evaluationsTotal.Add(1,
new KeyValuePair<string, object?>("status", status.ToString().ToLowerInvariant()),
new KeyValuePair<string, object?>("environment", environment),
new KeyValuePair<string, object?>("rule", matchedRule ?? "none"));
}
/// <summary>
/// Record a rule match.
/// </summary>
public void RecordRuleMatch(
string ruleName,
PolicyVerdictStatus status,
string environment)
{
_ruleMatchesTotal.Add(1,
new KeyValuePair<string, object?>("rule", ruleName),
new KeyValuePair<string, object?>("status", status.ToString().ToLowerInvariant()),
new KeyValuePair<string, object?>("environment", environment));
}
/// <summary>
/// Record an observation state transition.
/// </summary>
public void RecordStateTransition(
ObservationState fromState,
ObservationState toState,
string trigger,
string environment)
{
_stateTransitionsTotal.Add(1,
new KeyValuePair<string, object?>("from_state", fromState.ToString().ToLowerInvariant()),
new KeyValuePair<string, object?>("to_state", toState.ToString().ToLowerInvariant()),
new KeyValuePair<string, object?>("trigger", trigger),
new KeyValuePair<string, object?>("environment", environment));
}
/// <summary>
/// Record entropy value from evaluation.
/// </summary>
public void RecordEntropy(double entropy, string environment)
{
_entropyDistribution.Record(
entropy,
new KeyValuePair<string, object?>("environment", environment));
}
/// <summary>
/// Record trust score from evaluation.
/// </summary>
public void RecordTrustScore(double trustScore, string environment)
{
_trustScoreDistribution.Record(
trustScore,
new KeyValuePair<string, object?>("environment", environment));
}
/// <summary>
/// Record evaluation duration.
/// </summary>
public void RecordDuration(TimeSpan duration, string environment)
{
_evaluationDurationMs.Record(
duration.TotalMilliseconds,
new KeyValuePair<string, object?>("environment", environment));
}
/// <summary>
/// Create a timer scope for measuring evaluation duration.
/// </summary>
public IDisposable StartEvaluationTimer(string environment)
{
return new DurationScope(this, environment);
}
public void Dispose()
{
_meter.Dispose();
}
private sealed class DurationScope : IDisposable
{
private readonly DeterminizationGateMetrics _metrics;
private readonly string _environment;
private readonly Stopwatch _stopwatch;
public DurationScope(DeterminizationGateMetrics metrics, string environment)
{
_metrics = metrics;
_environment = environment;
_stopwatch = Stopwatch.StartNew();
}
public void Dispose()
{
_stopwatch.Stop();
_metrics.RecordDuration(_stopwatch.Elapsed, _environment);
}
}
}

View File

@@ -0,0 +1,21 @@
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Gates.Determinization;
/// <summary>
/// Builds signal snapshots for determinization evaluation.
/// </summary>
public interface ISignalSnapshotBuilder
{
/// <summary>
/// Build a signal snapshot for the given CVE/component pair.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="componentPurl">Component PURL.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Signal snapshot containing all available signals.</returns>
Task<SignalSnapshot> BuildAsync(
string cveId,
string componentPurl,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Gates.Determinization;
/// <summary>
/// Builds signal snapshots for determinization evaluation by querying signal repositories.
/// </summary>
public sealed class SignalSnapshotBuilder : ISignalSnapshotBuilder
{
private readonly ISignalRepository _signalRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SignalSnapshotBuilder> _logger;
public SignalSnapshotBuilder(
ISignalRepository signalRepository,
TimeProvider timeProvider,
ILogger<SignalSnapshotBuilder> logger)
{
_signalRepository = signalRepository;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<SignalSnapshot> BuildAsync(
string cveId,
string componentPurl,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
ArgumentException.ThrowIfNullOrWhiteSpace(componentPurl);
_logger.LogDebug(
"Building signal snapshot for CVE {CveId} on {Purl}",
cveId,
componentPurl);
var snapshotAt = _timeProvider.GetUtcNow();
var subjectKey = BuildSubjectKey(cveId, componentPurl);
// Query all signals in parallel
var signalsTask = _signalRepository.GetSignalsAsync(subjectKey, ct);
var signals = await signalsTask;
// Build snapshot from retrieved signals
var snapshot = SignalSnapshot.Empty(cveId, componentPurl, snapshotAt);
foreach (var signal in signals)
{
snapshot = ApplySignal(snapshot, signal);
}
_logger.LogDebug(
"Built signal snapshot for CVE {CveId} on {Purl}: {SignalCount} signals present",
cveId,
componentPurl,
signals.Count);
return snapshot;
}
private static string BuildSubjectKey(string cveId, string componentPurl)
=> $"{cveId}::{componentPurl}";
private SignalSnapshot ApplySignal(SignalSnapshot snapshot, Signal signal)
{
// This is a placeholder implementation
// In a real implementation, this would map Signal objects to SignalState<T> instances
// based on signal type and update the appropriate field in the snapshot
return snapshot;
}
}
/// <summary>
/// Repository for retrieving signals.
/// </summary>
public interface ISignalRepository
{
/// <summary>
/// Get all signals for the given subject key.
/// </summary>
Task<IReadOnlyList<Signal>> GetSignalsAsync(string subjectKey, CancellationToken ct = default);
}
/// <summary>
/// Represents a signal retrieved from storage.
/// </summary>
public sealed record Signal
{
public required string Type { get; init; }
public required string SubjectKey { get; init; }
public required DateTimeOffset ObservedAt { get; init; }
public required object? Evidence { get; init; }
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Immutable;
using StellaOps.Policy;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
/// Gate that evaluates determinization state and uncertainty for findings.
/// </summary>
public interface IDeterminizationGate : IPolicyGate
{
/// <summary>
/// Evaluate a finding against determinization thresholds.
/// </summary>
/// <param name="mergeResult">The merge result from trust lattice.</param>
/// <param name="context">Policy gate context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Determinization-specific gate evaluation result.</returns>
Task<DeterminizationGateResult> EvaluateDeterminizationAsync(
TrustLattice.MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default);
}
/// <summary>
/// Result of determinization gate evaluation.
/// </summary>
public sealed record DeterminizationGateResult
{
/// <summary>Whether the gate passed.</summary>
public required bool Passed { get; init; }
/// <summary>Policy verdict status.</summary>
public required PolicyVerdictStatus Status { get; init; }
/// <summary>Reason for the decision.</summary>
public required string Reason { get; init; }
/// <summary>Guardrails if GuardedPass.</summary>
public GuardRails? GuardRails { get; init; }
/// <summary>Uncertainty score.</summary>
public required UncertaintyScore UncertaintyScore { get; init; }
/// <summary>Decay information.</summary>
public required ObservationDecay Decay { get; init; }
/// <summary>Trust score.</summary>
public required double TrustScore { get; init; }
/// <summary>Rule that matched.</summary>
public string? MatchedRule { get; init; }
/// <summary>Additional metadata for audit.</summary>
public ImmutableDictionary<string, object>? Metadata { get; init; }
}

View File

@@ -1,3 +1,6 @@
using StellaOps.Facet;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Gates;
/// <summary>
@@ -35,6 +38,11 @@ public sealed class PolicyGateOptions
/// </summary>
public OverrideOptions Override { get; set; } = new();
/// <summary>
/// Facet quota gate options.
/// </summary>
public FacetQuotaGateOptions FacetQuota { get; set; } = new();
/// <summary>
/// Whether gates are enabled.
/// </summary>
@@ -139,3 +147,72 @@ public sealed class OverrideOptions
/// </summary>
public int MinJustificationLength { get; set; } = 20;
}
/// <summary>
/// Configuration options for the facet drift quota gate.
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-011)
/// </summary>
public sealed class FacetQuotaGateOptions
{
/// <summary>
/// Whether facet quota enforcement is enabled.
/// When disabled, the facet quota gate will skip evaluation.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default action when quota is exceeded and no facet-specific action is defined.
/// </summary>
public QuotaExceededAction DefaultAction { get; set; } = QuotaExceededAction.Warn;
/// <summary>
/// Default maximum churn percentage allowed before quota enforcement triggers.
/// </summary>
public decimal DefaultMaxChurnPercent { get; set; } = 10.0m;
/// <summary>
/// Default maximum number of changed files allowed before quota enforcement triggers.
/// </summary>
public int DefaultMaxChangedFiles { get; set; } = 50;
/// <summary>
/// Whether to skip quota check when no baseline seal is found.
/// </summary>
public bool SkipIfNoBaseline { get; set; } = true;
/// <summary>
/// SLA in days for VEX draft review when action is RequireVex.
/// </summary>
public int VexReviewSlaDays { get; set; } = 7;
/// <summary>
/// Per-facet quota overrides by facet ID.
/// </summary>
public Dictionary<string, FacetQuotaOverride> FacetOverrides { get; set; } = new();
}
/// <summary>
/// Per-facet quota configuration override.
/// </summary>
public sealed class FacetQuotaOverride
{
/// <summary>
/// Maximum churn percentage for this facet.
/// </summary>
public decimal? MaxChurnPercent { get; set; }
/// <summary>
/// Maximum changed files for this facet.
/// </summary>
public int? MaxChangedFiles { get; set; }
/// <summary>
/// Action when this facet's quota is exceeded.
/// </summary>
public QuotaExceededAction? Action { get; set; }
/// <summary>
/// Allowlist globs for files that don't count against quota.
/// </summary>
public List<string> AllowlistGlobs { get; set; } = new();
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
@@ -396,7 +397,7 @@ public sealed class IncrementalPolicyOrchestrator
{
var builder = new StringBuilder();
builder.Append(tenantId).Append('|');
builder.Append(createdAt.ToString("O")).Append('|');
builder.Append(createdAt.ToString("O", CultureInfo.InvariantCulture)).Append('|');
foreach (var evt in events.OrderBy(e => e.EventId, StringComparer.Ordinal))
{

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using StellaOps.Policy.Engine.Orchestration;
@@ -55,7 +56,7 @@ internal sealed class LedgerExportService
AdvisoryId: item.AdvisoryId,
Status: item.Status,
TraceRef: item.TraceRef,
OccurredAt: result.CompletedAt.ToString("O")));
OccurredAt: result.CompletedAt.ToString("O", CultureInfo.InvariantCulture)));
}
}
@@ -66,7 +67,7 @@ internal sealed class LedgerExportService
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
.ToList();
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
var generatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
var exportId = StableIdGenerator.CreateUlid($"{request.TenantId}|{generatedAt}|{ordered.Count}");
var recordLines = ordered.Select(r => JsonSerializer.Serialize(r, SerializerOptions)).ToList();

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
namespace StellaOps.Policy.Engine.Orchestration;
@@ -95,7 +96,7 @@ internal sealed class OrchestratorJobService
.Append(request.ContextId).Append('|')
.Append(request.PolicyProfileHash).Append('|')
.Append(priority).Append('|')
.Append(requestedAt.ToString("O"));
.Append(requestedAt.ToString("O", CultureInfo.InvariantCulture));
foreach (var item in items)
{

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
namespace StellaOps.Policy.Engine.Overlay;
@@ -36,7 +37,7 @@ internal sealed class OverlayChangeEventPublisher
string correlationId,
PathDecisionDelta? delta = null)
{
var emittedAt = _timeProvider.GetUtcNow().ToString("O");
var emittedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
return new OverlayChangeEvent(
Tenant: tenant,
RuleId: projection.RuleId,

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Streaming;
@@ -40,7 +41,7 @@ internal sealed class OverlayProjectionService
var projections = new List<OverlayProjection>(orderedTargets.Count);
var version = 1;
var effectiveAt = _timeProvider.GetUtcNow().ToString("O");
var effectiveAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
foreach (var target in orderedTargets)
{

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Policies;
/// <summary>
/// Implements allow/quarantine/escalate logic per advisory specification.
/// </summary>
public sealed class DeterminizationPolicy : IDeterminizationPolicy
{
private readonly DeterminizationOptions _options;
private readonly DeterminizationRuleSet _ruleSet;
private readonly ILogger<DeterminizationPolicy> _logger;
public DeterminizationPolicy(
IOptions<DeterminizationOptions> options,
ILogger<DeterminizationPolicy> logger)
{
_options = options.Value;
_ruleSet = DeterminizationRuleSet.Default(_options);
_logger = logger;
}
public DeterminizationResult Evaluate(DeterminizationContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
// Get environment-specific thresholds
var thresholds = GetEnvironmentThresholds(ctx.Environment);
// Evaluate rules in priority order
foreach (var rule in _ruleSet.Rules.OrderBy(r => r.Priority))
{
if (rule.Condition(ctx, thresholds))
{
var result = rule.Action(ctx, thresholds);
result = result with { MatchedRule = rule.Name };
_logger.LogDebug(
"Rule {RuleName} matched for CVE {CveId}: {Status}",
rule.Name,
ctx.SignalSnapshot.Cve,
result.Status);
return result;
}
}
// Default: Deferred (no rule matched, needs more evidence)
return DeterminizationResult.Deferred(
"No determinization rule matched; additional evidence required");
}
private EnvironmentThresholds GetEnvironmentThresholds(DeploymentEnvironment env)
{
return env switch
{
DeploymentEnvironment.Production => DefaultEnvironmentThresholds.Production,
DeploymentEnvironment.Staging => DefaultEnvironmentThresholds.Staging,
DeploymentEnvironment.Testing => DefaultEnvironmentThresholds.Development,
DeploymentEnvironment.Development => DefaultEnvironmentThresholds.Development,
_ => DefaultEnvironmentThresholds.Development
};
}
}
/// <summary>
/// Environment-specific thresholds for determinization decisions.
/// </summary>
public sealed record EnvironmentThresholds
{
public required DeploymentEnvironment Environment { get; init; }
public required double MinConfidenceForNotAffected { get; init; }
public required double MaxEntropyForAllow { get; init; }
public required double EpssBlockThreshold { get; init; }
public required bool RequireReachabilityForAllow { get; init; }
}
/// <summary>
/// Default environment thresholds per advisory.
/// </summary>
public static class DefaultEnvironmentThresholds
{
public static EnvironmentThresholds Production => new()
{
Environment = DeploymentEnvironment.Production,
MinConfidenceForNotAffected = 0.75,
MaxEntropyForAllow = 0.3,
EpssBlockThreshold = 0.3,
RequireReachabilityForAllow = true
};
public static EnvironmentThresholds Staging => new()
{
Environment = DeploymentEnvironment.Staging,
MinConfidenceForNotAffected = 0.60,
MaxEntropyForAllow = 0.5,
EpssBlockThreshold = 0.4,
RequireReachabilityForAllow = true
};
public static EnvironmentThresholds Development => new()
{
Environment = DeploymentEnvironment.Development,
MinConfidenceForNotAffected = 0.40,
MaxEntropyForAllow = 0.7,
EpssBlockThreshold = 0.6,
RequireReachabilityForAllow = false
};
}

View File

@@ -0,0 +1,220 @@
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Policies;
/// <summary>
/// Rule set for determinization policy evaluation.
/// Rules are evaluated in priority order (lower = higher priority).
/// </summary>
public sealed class DeterminizationRuleSet
{
public IReadOnlyList<DeterminizationRule> Rules { get; }
private DeterminizationRuleSet(IReadOnlyList<DeterminizationRule> rules)
{
Rules = rules;
}
/// <summary>
/// Creates the default rule set per advisory specification.
/// </summary>
public static DeterminizationRuleSet Default(DeterminizationOptions options) =>
new(new List<DeterminizationRule>
{
// Rule 1: Escalate if runtime evidence shows vulnerable code loaded
new DeterminizationRule
{
Name = "RuntimeEscalation",
Priority = 10,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Runtime.HasValue &&
ctx.SignalSnapshot.Runtime.Value!.ObservedLoaded,
Action = (ctx, _) =>
DeterminizationResult.Escalated(
"Runtime evidence shows vulnerable code loaded in memory")
},
// Rule 2: Quarantine if EPSS exceeds threshold
new DeterminizationRule
{
Name = "EpssQuarantine",
Priority = 20,
Condition = (ctx, thresholds) =>
ctx.SignalSnapshot.Epss.HasValue &&
ctx.SignalSnapshot.Epss.Value!.Score >= thresholds.EpssBlockThreshold,
Action = (ctx, thresholds) =>
DeterminizationResult.Quarantined(
$"EPSS score {ctx.SignalSnapshot.Epss.Value!.Score:P1} exceeds threshold {thresholds.EpssBlockThreshold:P1}")
},
// Rule 3: Quarantine if proven reachable
new DeterminizationRule
{
Name = "ReachabilityQuarantine",
Priority = 25,
Condition = (ctx, _) =>
ctx.SignalSnapshot.Reachability.HasValue &&
ctx.SignalSnapshot.Reachability.Value!.IsReachable,
Action = (ctx, _) =>
DeterminizationResult.Quarantined(
$"Vulnerable code is reachable via call graph analysis")
},
// Rule 4: Block high entropy in production
new DeterminizationRule
{
Name = "ProductionEntropyBlock",
Priority = 30,
Condition = (ctx, thresholds) =>
ctx.Environment == DeploymentEnvironment.Production &&
ctx.UncertaintyScore.Entropy > thresholds.MaxEntropyForAllow,
Action = (ctx, thresholds) =>
DeterminizationResult.Quarantined(
$"High uncertainty (entropy={ctx.UncertaintyScore.Entropy:F2}) exceeds production threshold ({thresholds.MaxEntropyForAllow:F2})")
},
// Rule 5: Defer if evidence is stale
new DeterminizationRule
{
Name = "StaleEvidenceDefer",
Priority = 40,
Condition = (ctx, _) => ctx.Decay.IsStale,
Action = (ctx, _) =>
DeterminizationResult.Deferred(
$"Evidence is stale (last update: {ctx.Decay.LastSignalUpdate:u}, age: {ctx.Decay.AgeDays:F1} days)")
},
// Rule 6: Guarded allow for uncertain observations in non-prod
new DeterminizationRule
{
Name = "GuardedAllowNonProd",
Priority = 50,
Condition = (ctx, _) =>
ctx.TrustScore < 0.5 &&
ctx.UncertaintyScore.Entropy > 0.4 &&
ctx.Environment != DeploymentEnvironment.Production,
Action = (ctx, _) =>
DeterminizationResult.GuardedPass(
$"Uncertain observation (entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}) allowed with guardrails in {ctx.Environment}",
BuildGuardrails(ctx, GuardRailsLevel.Moderate))
},
// Rule 7: Allow if unreachable with high confidence
new DeterminizationRule
{
Name = "UnreachableAllow",
Priority = 60,
Condition = (ctx, thresholds) =>
ctx.SignalSnapshot.Reachability.HasValue &&
!ctx.SignalSnapshot.Reachability.Value!.IsReachable &&
ctx.SignalSnapshot.Reachability.Value.Confidence >= thresholds.MinConfidenceForNotAffected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"Vulnerable code is unreachable (confidence={ctx.SignalSnapshot.Reachability.Value!.Confidence:P0})")
},
// Rule 8: Allow if VEX not_affected with trusted issuer
new DeterminizationRule
{
Name = "VexNotAffectedAllow",
Priority = 65,
Condition = (ctx, thresholds) =>
ctx.SignalSnapshot.Vex.HasValue &&
ctx.SignalSnapshot.Vex.Value!.IsNotAffected &&
ctx.SignalSnapshot.Vex.Value.IssuerTrust >= thresholds.MinConfidenceForNotAffected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"VEX statement indicates not_affected (trust={ctx.SignalSnapshot.Vex.Value!.IssuerTrust:P0})")
},
// Rule 9: Allow if sufficient evidence and low entropy
new DeterminizationRule
{
Name = "SufficientEvidenceAllow",
Priority = 70,
Condition = (ctx, thresholds) =>
ctx.UncertaintyScore.Entropy <= thresholds.MaxEntropyForAllow &&
ctx.TrustScore >= thresholds.MinConfidenceForNotAffected,
Action = (ctx, _) =>
DeterminizationResult.Allowed(
$"Sufficient evidence (entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}) for confident determination")
},
// Rule 10: Guarded allow for moderate uncertainty
new DeterminizationRule
{
Name = "GuardedAllowModerateUncertainty",
Priority = 80,
Condition = (ctx, _) =>
ctx.UncertaintyScore.Tier <= UncertaintyTier.Moderate &&
ctx.TrustScore >= 0.4,
Action = (ctx, _) =>
DeterminizationResult.GuardedPass(
$"Moderate uncertainty (tier={ctx.UncertaintyScore.Tier}, trust={ctx.TrustScore:F2}) allowed with monitoring",
BuildGuardrails(ctx, GuardRailsLevel.Light))
},
// Rule 11: Default - require more evidence
new DeterminizationRule
{
Name = "DefaultDefer",
Priority = 100,
Condition = (_, _) => true,
Action = (ctx, _) =>
DeterminizationResult.Deferred(
$"Insufficient evidence for determination (entropy={ctx.UncertaintyScore.Entropy:F2}, tier={ctx.UncertaintyScore.Tier})")
}
});
private enum GuardRailsLevel { Light, Moderate, Strict }
private static GuardRails BuildGuardrails(DeterminizationContext ctx, GuardRailsLevel level) =>
level switch
{
GuardRailsLevel.Light => new GuardRails
{
EnableMonitoring = true,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(14),
Notes = $"Light guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
},
GuardRailsLevel.Moderate => new GuardRails
{
EnableMonitoring = true,
RestrictToNonProd = ctx.Environment == DeploymentEnvironment.Production,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(7),
Notes = $"Moderate guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
},
GuardRailsLevel.Strict => new GuardRails
{
EnableMonitoring = true,
RestrictToNonProd = true,
RequireApproval = true,
ReevalAfter = TimeSpan.FromDays(3),
Notes = $"Strict guardrails: entropy={ctx.UncertaintyScore.Entropy:F2}, trust={ctx.TrustScore:F2}, env={ctx.Environment}"
},
_ => GuardRails.Default
};
}
/// <summary>
/// A single determinization rule.
/// </summary>
public sealed record DeterminizationRule
{
/// <summary>Rule name for audit/logging.</summary>
public required string Name { get; init; }
/// <summary>Priority (lower = evaluated first).</summary>
public required int Priority { get; init; }
/// <summary>Condition function.</summary>
public required Func<DeterminizationContext, EnvironmentThresholds, bool> Condition { get; init; }
/// <summary>Action function.</summary>
public required Func<DeterminizationContext, EnvironmentThresholds, DeterminizationResult> Action { get; init; }
}

View File

@@ -0,0 +1,53 @@
using StellaOps.Policy;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Policies;
/// <summary>
/// Policy for evaluating determinization decisions (allow/quarantine/escalate).
/// </summary>
public interface IDeterminizationPolicy
{
/// <summary>
/// Evaluate a CVE observation against determinization rules.
/// </summary>
/// <param name="context">Determinization context.</param>
/// <returns>Policy decision result.</returns>
DeterminizationResult Evaluate(DeterminizationContext context);
}
/// <summary>
/// Result of determinization policy evaluation.
/// </summary>
public sealed record DeterminizationResult
{
/// <summary>Policy verdict status.</summary>
public required PolicyVerdictStatus Status { get; init; }
/// <summary>Explanation of the decision.</summary>
public required string Reason { get; init; }
/// <summary>Guardrails if GuardedPass.</summary>
public GuardRails? GuardRails { get; init; }
/// <summary>Rule that matched.</summary>
public string? MatchedRule { get; init; }
/// <summary>Suggested observation state.</summary>
public ObservationState? SuggestedState { get; init; }
public static DeterminizationResult Allowed(string reason) =>
new() { Status = PolicyVerdictStatus.Pass, Reason = reason };
public static DeterminizationResult GuardedPass(string reason, GuardRails guardRails) =>
new() { Status = PolicyVerdictStatus.GuardedPass, Reason = reason, GuardRails = guardRails };
public static DeterminizationResult Quarantined(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Blocked) =>
new() { Status = status, Reason = reason };
public static DeterminizationResult Escalated(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Escalated) =>
new() { Status = status, Reason = reason };
public static DeterminizationResult Deferred(string reason, PolicyVerdictStatus status = PolicyVerdictStatus.Deferred) =>
new() { Status = status, Reason = reason };
}

View File

@@ -375,8 +375,8 @@ app.MapConflictsApi();
app.Run();
// Make Program class partial to allow integration testing while keeping it minimal
// Make Program class internal to prevent type conflicts when referencing this assembly
namespace StellaOps.Policy.Engine
{
public partial class Program { }
internal partial class Program { }
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.RiskProfile.Scope;
@@ -155,7 +156,7 @@ internal sealed class EffectivePolicyAuditor : IEffectivePolicyAuditor
var scope = new Dictionary<string, object?>
{
["event"] = eventType,
["timestamp"] = _timeProvider.GetUtcNow().ToString("O")
["timestamp"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
if (!string.IsNullOrWhiteSpace(actorId))

View File

@@ -8,9 +8,9 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryPolicyPackRepository(TimeProvider timeProvider)
public InMemoryPolicyPackRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
@@ -31,16 +31,15 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, now));
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, _timeProvider.GetUtcNow()));
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
var revision = pack.GetOrAddRevision(
revisionVersion,
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, now));
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, _timeProvider.GetUtcNow()));
if (revision.Status != initialStatus)
{
revision.SetStatus(initialStatus, now);
revision.SetStatus(initialStatus, _timeProvider.GetUtcNow());
}
return Task.FromResult(revision);
@@ -102,10 +101,9 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
{
ArgumentNullException.ThrowIfNull(bundle);
var now = _timeProvider.GetUtcNow();
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, now));
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, _timeProvider.GetUtcNow()));
var revision = pack.GetOrAddRevision(version > 0 ? version : pack.GetNextVersion(),
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, now));
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, _timeProvider.GetUtcNow()));
revision.SetBundle(bundle);
return Task.FromResult(bundle);

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using StellaOps.Policy.Engine.Ledger;
using StellaOps.Policy.Engine.Orchestration;
@@ -40,7 +41,7 @@ internal sealed class SnapshotService
.GroupBy(r => r.Status)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
var generatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
var snapshotId = StableIdGenerator.CreateUlid($"{export.Manifest.ExportId}|{request.OverlayHash}");
var snapshot = new SnapshotDetail(

View File

@@ -27,6 +27,7 @@
<ProjectReference Include="../../Router/__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />

View File

@@ -16,6 +16,12 @@ public sealed class InMemoryExceptionRepository(TimeProvider timeProvider, IGuid
private readonly TimeProvider _timeProvider = timeProvider;
private readonly IGuidProvider _guidProvider = guidProvider;
private readonly ConcurrentDictionary<(string Tenant, Guid Id), ExceptionEntity> _exceptions = new();
private readonly TimeProvider _timeProvider;
public InMemoryExceptionRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{

View File

@@ -0,0 +1,44 @@
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Events for signal updates that trigger re-evaluation.
/// </summary>
public static class DeterminizationEventTypes
{
public const string EpssUpdated = "epss.updated";
public const string VexUpdated = "vex.updated";
public const string ReachabilityUpdated = "reachability.updated";
public const string RuntimeUpdated = "runtime.updated";
public const string BackportUpdated = "backport.updated";
public const string ObservationStateChanged = "observation.state_changed";
}
/// <summary>
/// Event published when a signal is updated.
/// </summary>
public sealed record SignalUpdatedEvent
{
public required string EventType { get; init; }
public required string CveId { get; init; }
public required string Purl { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public required string Source { get; init; }
public object? NewValue { get; init; }
public object? PreviousValue { get; init; }
}
/// <summary>
/// Event published when observation state changes.
/// </summary>
public sealed record ObservationStateChangedEvent
{
public required Guid ObservationId { get; init; }
public required string CveId { get; init; }
public required string Purl { get; init; }
public required ObservationState PreviousState { get; init; }
public required ObservationState NewState { get; init; }
public required string Reason { get; init; }
public required DateTimeOffset ChangedAt { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Handler for signal update events.
/// </summary>
public interface ISignalUpdateSubscription
{
/// <summary>
/// Handle a signal update and re-evaluate affected observations.
/// </summary>
Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default);
}

View File

@@ -0,0 +1,113 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Gates;
namespace StellaOps.Policy.Engine.Subscriptions;
/// <summary>
/// Implementation of signal update handling.
/// </summary>
public sealed class SignalUpdateHandler : ISignalUpdateSubscription
{
private readonly IObservationRepository _observations;
private readonly IDeterminizationGate _gate;
private readonly IEventPublisher _eventPublisher;
private readonly ILogger<SignalUpdateHandler> _logger;
public SignalUpdateHandler(
IObservationRepository observations,
IDeterminizationGate gate,
IEventPublisher eventPublisher,
ILogger<SignalUpdateHandler> logger)
{
_observations = observations;
_gate = gate;
_eventPublisher = eventPublisher;
_logger = logger;
}
public async Task HandleAsync(SignalUpdatedEvent evt, CancellationToken ct = default)
{
_logger.LogInformation(
"Processing signal update: {EventType} for CVE {CveId} on {Purl}",
evt.EventType,
evt.CveId,
evt.Purl);
// Find observations affected by this signal
var affected = await _observations.FindByCveAndPurlAsync(evt.CveId, evt.Purl, ct);
foreach (var obs in affected)
{
try
{
await ReEvaluateObservationAsync(obs, evt, ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to re-evaluate observation {ObservationId} after signal update",
obs.Id);
}
}
}
private async Task ReEvaluateObservationAsync(
CveObservation obs,
SignalUpdatedEvent trigger,
CancellationToken ct)
{
// This is a placeholder for re-evaluation logic
// In a full implementation, this would:
// 1. Build PolicyGateContext from observation
// 2. Call gate.EvaluateDeterminizationAsync()
// 3. Compare new verdict with old verdict
// 4. Publish ObservationStateChangedEvent if state changed
// 5. Update observation in repository
_logger.LogDebug(
"Re-evaluating observation {ObservationId} after {EventType}",
obs.Id,
trigger.EventType);
await Task.CompletedTask;
}
}
/// <summary>
/// Repository for CVE observations.
/// </summary>
public interface IObservationRepository
{
/// <summary>
/// Find observations by CVE ID and component PURL.
/// </summary>
Task<IReadOnlyList<CveObservation>> FindByCveAndPurlAsync(
string cveId,
string purl,
CancellationToken ct = default);
}
/// <summary>
/// Event publisher abstraction.
/// </summary>
public interface IEventPublisher
{
/// <summary>
/// Publish an event.
/// </summary>
Task PublishAsync<TEvent>(TEvent evt, CancellationToken ct = default)
where TEvent : class;
}
/// <summary>
/// CVE observation model.
/// </summary>
public sealed record CveObservation
{
public required Guid Id { get; init; }
public required string CveId { get; init; }
public required string SubjectPurl { get; init; }
public required ObservationState State { get; init; }
public required DateTimeOffset ObservedAt { get; init; }
}

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0440-M | DONE | Maintainability audit for StellaOps.Policy.Engine. |
| AUDIT-0440-T | DONE | Test coverage audit for StellaOps.Policy.Engine. |
| AUDIT-0440-A | TODO | APPLY pending approval for StellaOps.Policy.Engine. |
| AUDIT-0440-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Engine. |
| AUDIT-0440-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Engine. |
| AUDIT-0440-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenTelemetry.Trace;
@@ -62,7 +63,7 @@ public sealed class IncidentModeService
_logger.LogWarning(
"Incident mode ENABLED. Reason: {Reason}, ExpiresAt: {ExpiresAt}",
reason,
expiresAt?.ToString("O") ?? "never");
expiresAt?.ToString("O", CultureInfo.InvariantCulture) ?? "never");
PolicyEngineTelemetry.RecordError("incident_mode_enabled", null);
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -48,7 +49,7 @@ internal sealed class TrustWeightingService
private IReadOnlyList<TrustWeightingEntry> Normalize(IReadOnlyList<TrustWeightingEntry> entries)
{
var now = _timeProvider.GetUtcNow().ToString("O");
var now = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
var normalized = entries
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
@@ -65,7 +66,7 @@ internal sealed class TrustWeightingService
private static IReadOnlyList<TrustWeightingEntry> DefaultWeights()
{
var now = TimeProvider.System.GetUtcNow().ToString("O");
var now = TimeProvider.System.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
return new[]
{
new TrustWeightingEntry("cartographer", 1.000m, null, now),

View File

@@ -2,12 +2,10 @@
// Sprint: SPRINT_20251226_003_BE_exception_approval
// Task: EXCEPT-05, EXCEPT-06, EXCEPT-07 - Exception approval API endpoints
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
@@ -91,8 +89,7 @@ public static class ExceptionApprovalEndpoints
CreateApprovalRequestDto request,
IExceptionApprovalRepository repository,
IExceptionApprovalRulesService rulesService,
TimeProvider timeProvider,
IGuidProvider guidProvider,
[FromServices] TimeProvider timeProvider,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
@@ -114,8 +111,7 @@ public static class ExceptionApprovalEndpoints
}
// Generate request ID
var now = timeProvider.GetUtcNow();
var requestId = $"EAR-{now.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}-{guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8].ToUpperInvariant()}";
var requestId = $"EAR-{timeProvider.GetUtcNow():yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
// Parse gate level
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
@@ -144,9 +140,10 @@ public static class ExceptionApprovalEndpoints
});
}
var now = timeProvider.GetUtcNow();
var entity = new ExceptionApprovalRequestEntity
{
Id = guidProvider.NewGuid(),
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
ExceptionId = request.ExceptionId,
@@ -208,7 +205,7 @@ public static class ExceptionApprovalEndpoints
// Record audit entry
await repository.RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = guidProvider.NewGuid(),
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = 1,

View File

@@ -8,7 +8,6 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Gateway.Contracts;
@@ -135,8 +134,7 @@ public static class ExceptionEndpoints
CreateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
IGuidProvider guidProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
@@ -148,10 +146,8 @@ public static class ExceptionEndpoints
});
}
var now = timeProvider.GetUtcNow();
// Validate expiry is in future
if (request.ExpiresAt <= now)
if (request.ExpiresAt <= timeProvider.GetUtcNow())
{
return Results.BadRequest(new ProblemDetails
{
@@ -162,7 +158,7 @@ public static class ExceptionEndpoints
}
// Validate expiry is not more than 1 year
if (request.ExpiresAt > now.AddYears(1))
if (request.ExpiresAt > timeProvider.GetUtcNow().AddYears(1))
{
return Results.BadRequest(new ProblemDetails
{
@@ -175,7 +171,7 @@ public static class ExceptionEndpoints
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var exceptionId = $"EXC-{guidProvider.NewGuid():N}"[..20];
var exceptionId = $"EXC-{Guid.NewGuid():N}"[..20];
var exception = new ExceptionObject
{
@@ -193,8 +189,8 @@ public static class ExceptionEndpoints
},
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = now,
UpdatedAt = now,
CreatedAt = timeProvider.GetUtcNow(),
UpdatedAt = timeProvider.GetUtcNow(),
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReasonRequired(request.ReasonCode),
Rationale = request.Rationale,
@@ -215,7 +211,7 @@ public static class ExceptionEndpoints
UpdateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -264,7 +260,7 @@ public static class ExceptionEndpoints
ApproveExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -297,13 +293,12 @@ public static class ExceptionEndpoints
});
}
var now = timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = now,
ApprovedAt = now,
UpdatedAt = timeProvider.GetUtcNow(),
ApprovedAt = timeProvider.GetUtcNow(),
ApproverIds = existing.ApproverIds.Add(actorId)
};
@@ -318,7 +313,7 @@ public static class ExceptionEndpoints
string id,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -359,7 +354,7 @@ public static class ExceptionEndpoints
ExtendExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -410,7 +405,7 @@ public static class ExceptionEndpoints
[FromBody] RevokeExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);

View File

@@ -2,12 +2,10 @@
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Audit;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Engine.Gates;
@@ -41,8 +39,7 @@ public static class GateEndpoints
IBaselineSelector baselineSelector,
IGateBypassAuditor bypassAuditor,
IMemoryCache cache,
TimeProvider timeProvider,
IGuidProvider guidProvider,
[FromServices] TimeProvider timeProvider,
ILogger<DriftGateEvaluator> logger,
CancellationToken cancellationToken) =>
{
@@ -83,7 +80,7 @@ public static class GateEndpoints
return Results.Ok(new GateEvaluateResponse
{
DecisionId = $"gate:{timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}:{guidProvider.NewGuid():N}",
DecisionId = $"gate:{timeProvider.GetUtcNow():yyyyMMddHHmmss}:{Guid.NewGuid():N}",
Status = GateStatus.Pass,
ExitCode = GateExitCodes.Pass,
ImageDigest = request.ImageDigest,
@@ -228,7 +225,8 @@ public static class GateEndpoints
.WithDescription("Retrieve a previous gate evaluation decision by ID");
// GET /api/v1/policy/gate/health - Health check for gate service
gates.MapGet("/health", (TimeProvider timeProvider) => Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) =>
Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
.WithName("GateHealth")
.WithDescription("Health check for the gate evaluation service");
}

View File

@@ -3,10 +3,9 @@
// Task: GOV-018 - Sealed mode overrides and risk profile events endpoints
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Determinism.Abstractions;
namespace StellaOps.Policy.Gateway.Endpoints;
@@ -102,11 +101,11 @@ public static class GovernanceEndpoints
private static Task<IResult> GetSealedModeStatusAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
[FromQuery] string? tenantId)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var response = new SealedModeStatusResponse
{
@@ -121,7 +120,7 @@ public static class GovernanceEndpoints
.Select(MapOverrideToResponse)
.ToList(),
VerificationStatus = "verified",
LastVerifiedAt = timeProvider.GetUtcNow().ToString("O")
LastVerifiedAt = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
return Task.FromResult(Results.Ok(response));
@@ -143,20 +142,21 @@ public static class GovernanceEndpoints
private static Task<IResult> ToggleSealedModeAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
SealedModeToggleRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
if (request.Enable)
{
state = new SealedModeState
{
IsSealed = true,
SealedAt = now.ToString("O"),
SealedAt = now.ToString("O", CultureInfo.InvariantCulture),
SealedBy = actor,
Reason = request.Reason,
TrustRoots = request.TrustRoots ?? [],
@@ -168,7 +168,7 @@ public static class GovernanceEndpoints
state = new SealedModeState
{
IsSealed = false,
LastUnsealedAt = now.ToString("O")
LastUnsealedAt = now.ToString("O", CultureInfo.InvariantCulture)
};
}
@@ -176,7 +176,7 @@ public static class GovernanceEndpoints
// Audit
RecordAudit(tenant, actor, "sealed_mode_toggled", "sealed-mode", "system_config",
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider, guidProvider);
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider);
var response = new SealedModeStatusResponse
{
@@ -188,7 +188,7 @@ public static class GovernanceEndpoints
AllowedSources = state.AllowedSources,
Overrides = [],
VerificationStatus = "verified",
LastVerifiedAt = now.ToString("O")
LastVerifiedAt = now.ToString("O", CultureInfo.InvariantCulture)
};
return Task.FromResult(Results.Ok(response));
@@ -196,15 +196,14 @@ public static class GovernanceEndpoints
private static Task<IResult> CreateSealedModeOverrideAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
SealedModeOverrideRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
var overrideId = $"override-{guidProvider.NewGuid():N}";
var overrideId = $"override-{Guid.NewGuid():N}";
var entity = new SealedModeOverrideEntity
{
Id = overrideId,
@@ -212,30 +211,29 @@ public static class GovernanceEndpoints
Type = request.Type,
Target = request.Target,
Reason = request.Reason,
ApprovalId = $"approval-{guidProvider.NewGuid():N}",
ApprovalId = $"approval-{Guid.NewGuid():N}",
ApprovedBy = [actor],
ExpiresAt = now.AddHours(request.DurationHours).ToString("O"),
CreatedAt = now.ToString("O"),
ExpiresAt = now.AddHours(request.DurationHours).ToString("O", CultureInfo.InvariantCulture),
CreatedAt = now.ToString("O", CultureInfo.InvariantCulture),
Active = true
};
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
$"Created override for {request.Target}: {request.Reason}", timeProvider, guidProvider);
$"Created override for {request.Target}: {request.Reason}", timeProvider);
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
}
private static Task<IResult> RevokeSealedModeOverrideAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string overrideId,
RevokeOverrideRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
if (!Overrides.TryGetValue(overrideId, out var entity) || entity.TenantId != tenant)
{
@@ -250,7 +248,7 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
$"Revoked override: {request.Reason}", timeProvider, guidProvider);
$"Revoked override: {request.Reason}", timeProvider);
return Task.FromResult(Results.NoContent());
}
@@ -296,15 +294,14 @@ public static class GovernanceEndpoints
private static Task<IResult> CreateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
CreateRiskProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
var profileId = $"profile-{guidProvider.NewGuid():N}";
var profileId = $"profile-{Guid.NewGuid():N}";
var entity = new RiskProfileEntity
{
Id = profileId,
@@ -317,8 +314,8 @@ public static class GovernanceEndpoints
Signals = request.Signals ?? [],
SeverityOverrides = request.SeverityOverrides ?? [],
ActionOverrides = request.ActionOverrides ?? [],
CreatedAt = now.ToString("O"),
ModifiedAt = now.ToString("O"),
CreatedAt = now.ToString("O", CultureInfo.InvariantCulture),
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
CreatedBy = actor,
ModifiedBy = actor
};
@@ -326,20 +323,19 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_created", profileId, "risk_profile",
$"Created risk profile: {request.Name}", timeProvider, guidProvider);
$"Created risk profile: {request.Name}", timeProvider);
return Task.FromResult(Results.Created($"/api/v1/governance/risk-profiles/{profileId}", MapProfileToResponse(entity)));
}
private static Task<IResult> UpdateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId,
UpdateRiskProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
@@ -358,26 +354,25 @@ public static class GovernanceEndpoints
Signals = request.Signals ?? existing.Signals,
SeverityOverrides = request.SeverityOverrides ?? existing.SeverityOverrides,
ActionOverrides = request.ActionOverrides ?? existing.ActionOverrides,
ModifiedAt = now.ToString("O"),
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
ModifiedBy = actor
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_updated", profileId, "risk_profile",
$"Updated risk profile: {entity.Name}", timeProvider, guidProvider);
$"Updated risk profile: {entity.Name}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> DeleteRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
if (!RiskProfiles.TryRemove(profileId, out var removed))
{
@@ -389,19 +384,18 @@ public static class GovernanceEndpoints
}
RecordAudit(tenant, actor, "risk_profile_deleted", profileId, "risk_profile",
$"Deleted risk profile: {removed.Name}", timeProvider, guidProvider);
$"Deleted risk profile: {removed.Name}", timeProvider);
return Task.FromResult(Results.NoContent());
}
private static Task<IResult> ActivateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
@@ -416,27 +410,26 @@ public static class GovernanceEndpoints
var entity = existing with
{
Status = "active",
ModifiedAt = now.ToString("O"),
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
ModifiedBy = actor
};
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_activated", profileId, "risk_profile",
$"Activated risk profile: {entity.Name}", timeProvider, guidProvider);
$"Activated risk profile: {entity.Name}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> DeprecateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId,
DeprecateProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
@@ -451,7 +444,7 @@ public static class GovernanceEndpoints
var entity = existing with
{
Status = "deprecated",
ModifiedAt = now.ToString("O"),
ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture),
ModifiedBy = actor,
DeprecationReason = request.Reason
};
@@ -459,7 +452,7 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider, guidProvider);
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
@@ -559,7 +552,7 @@ public static class GovernanceEndpoints
{
if (RiskProfiles.IsEmpty)
{
var now = TimeProvider.System.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
var now = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
RiskProfiles["profile-default"] = new RiskProfileEntity
{
Id = "profile-default",
@@ -599,15 +592,15 @@ public static class GovernanceEndpoints
?? httpContext.Request.Headers["X-StellaOps-Actor"].FirstOrDefault();
}
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider, IGuidProvider guidProvider)
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider)
{
var id = $"audit-{guidProvider.NewGuid():N}";
var id = $"audit-{Guid.NewGuid():N}";
AuditEntries[id] = new GovernanceAuditEntry
{
Id = id,
TenantId = tenantId,
Type = eventType,
Timestamp = timeProvider.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
Timestamp = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
Actor = actor,
ActorType = "user",
TargetResource = targetId,

View File

@@ -50,7 +50,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
[FromBody] DockerRegistryNotification notification,
IGateEvaluationQueue evaluationQueue,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -101,7 +101,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
[FromBody] HarborWebhookEvent notification,
IGateEvaluationQueue evaluationQueue,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -161,7 +161,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
[FromBody] GenericRegistryWebhook notification,
IGateEvaluationQueue evaluationQueue,
TimeProvider timeProvider,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{

View File

@@ -5,11 +5,9 @@
// Description: In-memory queue for gate evaluation jobs with background processing
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Gateway.Endpoints;
@@ -24,14 +22,14 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
private readonly Channel<GateEvaluationJob> _channel;
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger, TimeProvider timeProvider, IGuidProvider guidProvider)
public InMemoryGateEvaluationQueue(
ILogger<InMemoryGateEvaluationQueue> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
_timeProvider = timeProvider;
_guidProvider = guidProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
// Bounded channel to prevent unbounded memory growth
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
@@ -74,8 +72,8 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
private string GenerateJobId()
{
// Format: gate-{timestamp}-{random}
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture);
var random = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8];
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var random = Guid.NewGuid().ToString("N")[..8];
return $"gate-{timestamp}-{random}";
}
}
@@ -92,7 +90,7 @@ public sealed record GateEvaluationJob
/// <summary>
/// Background service that processes gate evaluation jobs from the queue.
/// Orchestrates: image analysis drift delta computation gate evaluation.
/// Orchestrates: image analysis -> drift delta computation -> gate evaluation.
/// </summary>
public sealed class GateEvaluationWorker : BackgroundService
{

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0445-M | DONE | Maintainability audit for StellaOps.Policy.Gateway. |
| AUDIT-0445-T | DONE | Test coverage audit for StellaOps.Policy.Gateway. |
| AUDIT-0445-A | TODO | APPLY pending approval for StellaOps.Policy.Gateway. |
| AUDIT-0445-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Gateway. |
| AUDIT-0445-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Gateway. |
| AUDIT-0445-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -2,6 +2,7 @@
// Sprint: SPRINT_5200_0001_0001 - Starter Policy Template
// Task: T7 - Policy Pack Distribution
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
@@ -344,7 +345,7 @@ public sealed class PolicyPackOciPublisher : IPolicyPackOciPublisher
{
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"),
["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture),
["org.opencontainers.image.title"] = request.PackName,
["org.opencontainers.image.version"] = request.PackVersion,
["stellaops.policy.pack.name"] = request.PackName,

View File

@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -11,12 +10,10 @@ public sealed class InMemoryOverrideStore : IOverrideStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryOverrideStore(TimeProvider timeProvider, IGuidProvider guidProvider)
public InMemoryOverrideStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<OverrideEntity> CreateAsync(
@@ -26,7 +23,7 @@ public sealed class InMemoryOverrideStore : IOverrideStore
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var overrideId = _guidProvider.NewGuid();
var overrideId = Guid.NewGuid();
var entity = new OverrideEntity
{

View File

@@ -2,7 +2,6 @@ using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -15,12 +14,10 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryPolicyPackStore(TimeProvider timeProvider, IGuidProvider guidProvider)
public InMemoryPolicyPackStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<PolicyPackEntity> CreateAsync(
@@ -30,7 +27,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var packId = _guidProvider.NewGuid();
var packId = Guid.NewGuid();
var entity = new PolicyPackEntity
{

View File

@@ -2,7 +2,6 @@ using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -10,11 +9,15 @@ namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of ISnapshotStore for testing and development.
/// </summary>
public sealed class InMemorySnapshotStore(TimeProvider timeProvider, IGuidProvider guidProvider) : ISnapshotStore
public sealed class InMemorySnapshotStore : ISnapshotStore
{
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
private readonly IGuidProvider _guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
private readonly TimeProvider _timeProvider;
public InMemorySnapshotStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<SnapshotEntity> CreateAsync(
Guid tenantId,
@@ -23,7 +26,7 @@ public sealed class InMemorySnapshotStore(TimeProvider timeProvider, IGuidProvid
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var snapshotId = _guidProvider.NewGuid();
var snapshotId = Guid.NewGuid();
// Compute digest from pack IDs and timestamp for uniqueness
var digest = ComputeDigest(request.PackIds, now);

View File

@@ -10,6 +10,12 @@ public sealed class InMemoryVerificationPolicyStore(TimeProvider timeProvider) :
{
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
private readonly TimeProvider _timeProvider;
public InMemoryVerificationPolicyStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<VerificationPolicyEntity> CreateAsync(
Guid tenantId,

View File

@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -11,12 +10,10 @@ public sealed class InMemoryViolationStore : IViolationStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryViolationStore(TimeProvider timeProvider, IGuidProvider guidProvider)
public InMemoryViolationStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<ViolationEntity> AppendAsync(
@@ -25,7 +22,7 @@ public sealed class InMemoryViolationStore : IViolationStore
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var violationId = _guidProvider.NewGuid();
var violationId = Guid.NewGuid();
var entity = new ViolationEntity
{
@@ -61,7 +58,7 @@ public sealed class InMemoryViolationStore : IViolationStore
try
{
var request = requests[i];
var violationId = _guidProvider.NewGuid();
var violationId = Guid.NewGuid();
var entity = new ViolationEntity
{

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0450-M | DONE | Maintainability audit for StellaOps.Policy.Registry. |
| AUDIT-0450-T | DONE | Test coverage audit for StellaOps.Policy.Registry. |
| AUDIT-0450-A | TODO | APPLY pending approval for StellaOps.Policy.Registry. |
| AUDIT-0450-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Registry. |
| AUDIT-0450-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Registry. |
| AUDIT-0450-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0451-M | DONE | Maintainability audit for StellaOps.Policy.RiskProfile. |
| AUDIT-0451-T | DONE | Test coverage audit for StellaOps.Policy.RiskProfile. |
| AUDIT-0451-A | TODO | APPLY pending approval for StellaOps.Policy.RiskProfile. |
| AUDIT-0451-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.RiskProfile. |
| AUDIT-0451-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.RiskProfile. |
| AUDIT-0451-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -50,7 +50,7 @@ internal static class ReceiptCanonicalizer
// If the value looks like a timestamp, normalize to ISO 8601 round-trip
if (DateTimeOffset.TryParse(element.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto))
{
writer.WriteStringValue(dto.ToUniversalTime().ToString("O"));
writer.WriteStringValue(dto.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
else
{

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0453-M | DONE | Maintainability audit for StellaOps.Policy.Scoring. |
| AUDIT-0453-T | DONE | Test coverage audit for StellaOps.Policy.Scoring. |
| AUDIT-0453-A | TODO | Awaiting approval to apply changes. |
| AUDIT-0453-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Scoring. |
| AUDIT-0453-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Scoring. |
| AUDIT-0453-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0439-M | DONE | Maintainability audit for StellaOps.Policy.AuthSignals. |
| AUDIT-0439-T | DONE | Test coverage audit for StellaOps.Policy.AuthSignals. |
| AUDIT-0439-A | TODO | APPLY pending approval for StellaOps.Policy.AuthSignals. |
| AUDIT-0439-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.AuthSignals. |
| AUDIT-0439-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.AuthSignals. |
| AUDIT-0439-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -0,0 +1,171 @@
# StellaOps.Policy.Determinization - Agent Guide
## Module Overview
The **Determinization** library handles CVEs that arrive without complete evidence (EPSS, VEX, reachability). It treats unknown observations as probabilistic with entropy-weighted trust that matures as evidence arrives.
**Key Concepts:**
- `ObservationState`: Lifecycle state for CVE observations (PendingDeterminization, Determined, Disputed, etc.)
- `SignalState<T>`: Null-aware wrapper distinguishing "not queried" from "queried but absent"
- `UncertaintyScore`: Knowledge completeness measurement (high entropy = missing signals)
- `ObservationDecay`: Time-based confidence decay with configurable half-life
- `GuardRails`: Monitoring requirements when allowing uncertain observations
## Directory Structure
```
src/Policy/__Libraries/StellaOps.Policy.Determinization/
├── Models/ # Core data models
│ ├── ObservationState.cs
│ ├── SignalState.cs
│ ├── SignalSnapshot.cs
│ ├── UncertaintyScore.cs
│ ├── ObservationDecay.cs
│ ├── GuardRails.cs
│ └── DeterminizationContext.cs
├── Evidence/ # Signal evidence types
│ ├── EpssEvidence.cs
│ ├── VexClaimSummary.cs
│ ├── ReachabilityEvidence.cs
│ └── ...
├── Scoring/ # Calculation services
│ ├── UncertaintyScoreCalculator.cs
│ ├── DecayedConfidenceCalculator.cs
│ ├── TrustScoreAggregator.cs
│ └── SignalWeights.cs
├── Policies/ # Policy rules (in Policy.Engine)
└── DeterminizationOptions.cs
```
## Key Patterns
### 1. SignalState<T> Usage
Always use `SignalState<T>` to wrap signal values:
```csharp
// Good - explicit status
var epss = SignalState<EpssEvidence>.WithValue(evidence, queriedAt, "first.org");
var vex = SignalState<VexClaimSummary>.Absent(queriedAt, "vendor");
var reach = SignalState<ReachabilityEvidence>.NotQueried();
var failed = SignalState<CvssEvidence>.Failed("Timeout");
// Bad - nullable without status
EpssEvidence? epss = null; // Can't tell if not queried or absent
```
### 2. Uncertainty Calculation
Entropy = 1 - (weighted present signals / max weight):
```csharp
// All signals present = 0.0 entropy (fully certain)
// No signals present = 1.0 entropy (fully uncertain)
// Formula uses configurable weights per signal type
```
### 3. Decay Calculation
Exponential decay with floor:
```csharp
decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
// Default: 14-day half-life, 0.35 floor
// After 14 days: ~50% confidence
// After 28 days: ~35% confidence (floor)
```
### 4. Policy Rules
Rules evaluate in priority order (lower = first):
| Priority | Rule | Outcome |
|----------|------|---------|
| 10 | Runtime shows loaded | Escalated |
| 20 | EPSS >= threshold | Blocked |
| 25 | Proven reachable | Blocked |
| 30 | High entropy in prod | Blocked |
| 40 | Evidence stale | Deferred |
| 50 | Uncertain + non-prod | GuardedPass |
| 60 | Unreachable + confident | Pass |
| 70 | Sufficient evidence | Pass |
| 100 | Default | Deferred |
## Testing Guidelines
### Unit Tests Required
1. `SignalState<T>` factory methods
2. `UncertaintyScoreCalculator` entropy bounds [0.0, 1.0]
3. `DecayedConfidenceCalculator` half-life formula
4. Policy rule priority ordering
5. State transition logic
### Property Tests
- Entropy always in [0.0, 1.0]
- Decay monotonically decreasing with age
- Same snapshot produces same uncertainty
### Integration Tests
- DI registration with configuration
- Signal snapshot building
- Policy gate evaluation
## Configuration
```yaml
Determinization:
EpssQuarantineThreshold: 0.4
GuardedAllowScoreThreshold: 0.5
GuardedAllowEntropyThreshold: 0.4
ProductionBlockEntropyThreshold: 0.3
DecayHalfLifeDays: 14
DecayFloor: 0.35
GuardedReviewIntervalDays: 7
MaxGuardedDurationDays: 30
SignalWeights:
Vex: 0.25
Epss: 0.15
Reachability: 0.25
Runtime: 0.15
Backport: 0.10
SbomLineage: 0.10
```
## Common Pitfalls
1. **Don't confuse EntropySignal with UncertaintyScore**: `EntropySignal` measures code complexity; `UncertaintyScore` measures knowledge completeness.
2. **Always inject TimeProvider**: Never use `DateTime.UtcNow` directly for decay calculations.
3. **Normalize weights before calculation**: Call `SignalWeights.Normalize()` to ensure weights sum to 1.0.
4. **Check signal status before accessing value**: `signal.HasValue` must be true before using `signal.Value!`.
5. **Handle all ObservationStates**: Switch expressions must be exhaustive.
## Dependencies
- `StellaOps.Policy` (PolicyVerdictStatus, existing confidence models)
- `System.Collections.Immutable` (ImmutableArray for collections)
- `Microsoft.Extensions.Options` (configuration)
- `Microsoft.Extensions.Logging` (logging)
## Related Modules
- **Policy.Engine**: DeterminizationGate integrates with policy pipeline
- **Feedser**: Signal attachers emit SignalState<T>
- **VexLens**: VEX updates emit SignalUpdatedEvent
- **Graph**: CVE nodes carry ObservationState and UncertaintyScore
- **Findings**: Observation persistence and audit trail
## Sprint References
- SPRINT_20260106_001_001_LB: Core models
- SPRINT_20260106_001_002_LB: Scoring services
- SPRINT_20260106_001_003_POLICY: Policy integration
- SPRINT_20260106_001_004_BE: Backend integration
- SPRINT_20260106_001_005_FE: Frontend UI

View File

@@ -0,0 +1,40 @@
namespace StellaOps.Policy.Determinization;
/// <summary>
/// Configuration options for the Determinization subsystem.
/// </summary>
public sealed record DeterminizationOptions
{
/// <summary>Default section name in appsettings.json.</summary>
public const string SectionName = "Determinization";
/// <summary>Signal weights for entropy calculation (default: advisory-recommended weights).</summary>
public Scoring.SignalWeights SignalWeights { get; init; } = Scoring.SignalWeights.Default;
/// <summary>Prior distribution for missing signals (default: Conservative).</summary>
public Scoring.PriorDistribution PriorDistribution { get; init; } = Scoring.PriorDistribution.Conservative;
/// <summary>Half-life for confidence decay in days (default: 14 days).</summary>
public double ConfidenceHalfLifeDays { get; init; } = 14.0;
/// <summary>Minimum confidence floor after decay (default: 0.1).</summary>
public double ConfidenceFloor { get; init; } = 0.1;
/// <summary>Threshold for triggering manual review (default: entropy >= 0.60).</summary>
public double ManualReviewEntropyThreshold { get; init; } = 0.60;
/// <summary>Threshold for triggering refresh (default: entropy >= 0.40).</summary>
public double RefreshEntropyThreshold { get; init; } = 0.40;
/// <summary>Maximum age before observation is considered stale (default: 30 days).</summary>
public double StaleObservationDays { get; init; } = 30.0;
/// <summary>Enable detailed determinization logging (default: false).</summary>
public bool EnableDetailedLogging { get; init; } = false;
/// <summary>Enable automatic refresh for stale observations (default: true).</summary>
public bool EnableAutoRefresh { get; init; } = true;
/// <summary>Maximum retry attempts for failed signal queries (default: 3).</summary>
public int MaxSignalQueryRetries { get; init; } = 3;
}

View File

@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Backport detection evidence.
/// </summary>
public sealed record BackportEvidence
{
/// <summary>
/// Backport detected.
/// </summary>
[JsonPropertyName("detected")]
public required bool Detected { get; init; }
/// <summary>
/// Backport source (e.g., "vendor-advisory", "patch-diff", "build-id").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Vendor package version.
/// </summary>
[JsonPropertyName("vendor_version")]
public string? VendorVersion { get; init; }
/// <summary>
/// Upstream version.
/// </summary>
[JsonPropertyName("upstream_version")]
public string? UpstreamVersion { get; init; }
/// <summary>
/// Patch identifier (e.g., commit hash, KB number).
/// </summary>
[JsonPropertyName("patch_id")]
public string? PatchId { get; init; }
/// <summary>
/// When this backport was detected (UTC).
/// </summary>
[JsonPropertyName("detected_at")]
public required DateTimeOffset DetectedAt { get; init; }
/// <summary>
/// Confidence in this evidence [0.0, 1.0].
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// CVSS (Common Vulnerability Scoring System) evidence.
/// </summary>
public sealed record CvssEvidence
{
/// <summary>
/// CVSS version (e.g., "3.1", "4.0").
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Base score [0.0, 10.0].
/// </summary>
[JsonPropertyName("base_score")]
public required double BaseScore { get; init; }
/// <summary>
/// Severity (e.g., "LOW", "MEDIUM", "HIGH", "CRITICAL").
/// </summary>
[JsonPropertyName("severity")]
public required string Severity { get; init; }
/// <summary>
/// Vector string (e.g., "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").
/// </summary>
[JsonPropertyName("vector")]
public string? Vector { get; init; }
/// <summary>
/// Source of CVSS score (e.g., "NVD", "vendor").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// When this CVSS score was published (UTC).
/// </summary>
[JsonPropertyName("published_at")]
public required DateTimeOffset PublishedAt { get; init; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// EPSS (Exploit Prediction Scoring System) evidence.
/// </summary>
public sealed record EpssEvidence
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// EPSS score [0.0, 1.0].
/// Probability of exploitation in the next 30 days.
/// </summary>
[JsonPropertyName("epss")]
public required double Epss { get; init; }
/// <summary>
/// EPSS percentile [0.0, 1.0].
/// </summary>
[JsonPropertyName("percentile")]
public required double Percentile { get; init; }
/// <summary>
/// When this EPSS value was published (UTC).
/// </summary>
[JsonPropertyName("published_at")]
public required DateTimeOffset PublishedAt { get; init; }
/// <summary>
/// EPSS model version.
/// </summary>
[JsonPropertyName("model_version")]
public string? ModelVersion { get; init; }
/// <summary>
/// Convenience property returning the EPSS score.
/// Alias for <see cref="Epss"/> for API compatibility.
/// </summary>
[JsonIgnore]
public double Score => Epss;
}

View File

@@ -0,0 +1,72 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Reachability analysis evidence.
/// </summary>
public sealed record ReachabilityEvidence
{
/// <summary>
/// Reachability status.
/// </summary>
[JsonPropertyName("status")]
public required ReachabilityStatus Status { get; init; }
/// <summary>
/// Call path depth (if reachable).
/// </summary>
[JsonPropertyName("depth")]
public int? Depth { get; init; }
/// <summary>
/// Entry point function name (if reachable).
/// </summary>
[JsonPropertyName("entry_point")]
public string? EntryPoint { get; init; }
/// <summary>
/// Vulnerable function name.
/// </summary>
[JsonPropertyName("vulnerable_function")]
public string? VulnerableFunction { get; init; }
/// <summary>
/// When this reachability analysis was performed (UTC).
/// </summary>
[JsonPropertyName("analyzed_at")]
public required DateTimeOffset AnalyzedAt { get; init; }
/// <summary>
/// PathWitness digest (if available).
/// </summary>
[JsonPropertyName("witness_digest")]
public string? WitnessDigest { get; init; }
/// <summary>
/// Analysis confidence [0.0, 1.0].
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Convenience property indicating if code is reachable.
/// </summary>
[JsonIgnore]
public bool IsReachable => Status == ReachabilityStatus.Reachable;
}
/// <summary>
/// Reachability status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Vulnerable code is reachable from entry points.</summary>
Reachable,
/// <summary>Vulnerable code is not reachable.</summary>
Unreachable,
/// <summary>Reachability indeterminate (analysis incomplete or failed).</summary>
Indeterminate
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// Runtime detection evidence.
/// </summary>
public sealed record RuntimeEvidence
{
/// <summary>
/// Runtime detection status.
/// </summary>
[JsonPropertyName("detected")]
public required bool Detected { get; init; }
/// <summary>
/// Detection source (e.g., "tracer", "eBPF", "logs").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Number of invocations detected.
/// </summary>
[JsonPropertyName("invocation_count")]
public int? InvocationCount { get; init; }
/// <summary>
/// When runtime observation started (UTC).
/// </summary>
[JsonPropertyName("observation_start")]
public required DateTimeOffset ObservationStart { get; init; }
/// <summary>
/// When runtime observation ended (UTC).
/// </summary>
[JsonPropertyName("observation_end")]
public required DateTimeOffset ObservationEnd { get; init; }
/// <summary>
/// Confidence in this evidence [0.0, 1.0].
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Convenience property indicating if vulnerable code was observed loaded.
/// Alias for <see cref="Detected"/> for API compatibility.
/// </summary>
[JsonIgnore]
public bool ObservedLoaded => Detected;
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// SBOM lineage evidence.
/// Tracks provenance and chain of custody.
/// </summary>
public sealed record SbomLineageEvidence
{
/// <summary>
/// SBOM digest.
/// </summary>
[JsonPropertyName("sbom_digest")]
public required string SbomDigest { get; init; }
/// <summary>
/// SBOM format (e.g., "SPDX", "CycloneDX").
/// </summary>
[JsonPropertyName("format")]
public required string Format { get; init; }
/// <summary>
/// Attestation digest (DSSE envelope).
/// </summary>
[JsonPropertyName("attestation_digest")]
public string? AttestationDigest { get; init; }
/// <summary>
/// Number of components in SBOM.
/// </summary>
[JsonPropertyName("component_count")]
public required int ComponentCount { get; init; }
/// <summary>
/// When this SBOM was generated (UTC).
/// </summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Build provenance available.
/// </summary>
[JsonPropertyName("has_provenance")]
public required bool HasProvenance { get; init; }
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Evidence;
/// <summary>
/// VEX (Vulnerability Exploitability eXchange) claim summary.
/// </summary>
public sealed record VexClaimSummary
{
/// <summary>
/// VEX status.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; } // "affected", "not_affected", "fixed", "under_investigation"
/// <summary>
/// Confidence in this claim [0.0, 1.0].
/// Weighted average if multiple sources.
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Number of VEX statements supporting this claim.
/// </summary>
[JsonPropertyName("statement_count")]
public required int StatementCount { get; init; }
/// <summary>
/// When this summary was computed (UTC).
/// </summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Justification text (if provided).
/// </summary>
[JsonPropertyName("justification")]
public string? Justification { get; init; }
/// <summary>
/// Convenience property indicating if the VEX status is "not_affected".
/// </summary>
[JsonIgnore]
public bool IsNotAffected => string.Equals(Status, "not_affected", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Issuer trust level [0.0, 1.0].
/// Alias for <see cref="Confidence"/> for API compatibility.
/// </summary>
[JsonIgnore]
public double IssuerTrust => Confidence;
}

View File

@@ -0,0 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;

View File

@@ -0,0 +1,133 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Context for determinization evaluation.
/// Contains environment, criticality, policy settings, and computed evidence data.
/// </summary>
public sealed record DeterminizationContext
{
/// <summary>
/// Deployment environment.
/// </summary>
[JsonPropertyName("environment")]
public required DeploymentEnvironment Environment { get; init; }
/// <summary>
/// Asset criticality level.
/// </summary>
[JsonPropertyName("criticality")]
public AssetCriticality Criticality { get; init; } = AssetCriticality.Medium;
/// <summary>
/// Entropy threshold for this context.
/// Observations above this trigger guardrails.
/// </summary>
[JsonPropertyName("entropy_threshold")]
public double EntropyThreshold { get; init; } = 0.5;
/// <summary>
/// Decay threshold for this context.
/// Observations below this are considered stale.
/// </summary>
[JsonPropertyName("decay_threshold")]
public double DecayThreshold { get; init; } = 0.5;
/// <summary>
/// Signal snapshot containing evidence from various sources.
/// </summary>
[JsonPropertyName("signal_snapshot")]
public required SignalSnapshot SignalSnapshot { get; init; }
/// <summary>
/// Calculated uncertainty score for this context.
/// </summary>
[JsonPropertyName("uncertainty_score")]
public required UncertaintyScore UncertaintyScore { get; init; }
/// <summary>
/// Observation decay information.
/// </summary>
[JsonPropertyName("decay")]
public required ObservationDecay Decay { get; init; }
/// <summary>
/// Aggregated trust score (0.0-1.0).
/// </summary>
[JsonPropertyName("trust_score")]
public required double TrustScore { get; init; }
/// <summary>
/// Creates context with default production settings and placeholder signal data.
/// </summary>
public static DeterminizationContext Production(
SignalSnapshot? snapshot = null,
UncertaintyScore? uncertaintyScore = null,
ObservationDecay? decay = null,
double trustScore = 0.5)
{
var now = DateTimeOffset.UtcNow;
return new()
{
Environment = DeploymentEnvironment.Production,
Criticality = AssetCriticality.High,
EntropyThreshold = 0.4,
DecayThreshold = 0.50,
SignalSnapshot = snapshot ?? SignalSnapshot.Empty("CVE-0000-0000", "pkg:unknown/unknown@0.0.0", now),
UncertaintyScore = uncertaintyScore ?? UncertaintyScore.Zero(1.0, now),
Decay = decay ?? ObservationDecay.Fresh(now),
TrustScore = trustScore
};
}
/// <summary>
/// Creates context with relaxed development settings and placeholder signal data.
/// </summary>
public static DeterminizationContext Development(
SignalSnapshot? snapshot = null,
UncertaintyScore? uncertaintyScore = null,
ObservationDecay? decay = null,
double trustScore = 0.5)
{
var now = DateTimeOffset.UtcNow;
return new()
{
Environment = DeploymentEnvironment.Development,
Criticality = AssetCriticality.Low,
EntropyThreshold = 0.6,
DecayThreshold = 0.35,
SignalSnapshot = snapshot ?? SignalSnapshot.Empty("CVE-0000-0000", "pkg:unknown/unknown@0.0.0", now),
UncertaintyScore = uncertaintyScore ?? UncertaintyScore.Zero(1.0, now),
Decay = decay ?? ObservationDecay.Fresh(now),
TrustScore = trustScore
};
}
/// <summary>
/// Creates context with custom thresholds and placeholder signal data.
/// </summary>
public static DeterminizationContext Create(
DeploymentEnvironment environment,
AssetCriticality criticality,
double entropyThreshold,
double decayThreshold,
SignalSnapshot? snapshot = null,
UncertaintyScore? uncertaintyScore = null,
ObservationDecay? decay = null,
double trustScore = 0.5)
{
var now = DateTimeOffset.UtcNow;
return new()
{
Environment = environment,
Criticality = criticality,
EntropyThreshold = entropyThreshold,
DecayThreshold = decayThreshold,
SignalSnapshot = snapshot ?? SignalSnapshot.Empty("CVE-0000-0000", "pkg:unknown/unknown@0.0.0", now),
UncertaintyScore = uncertaintyScore ?? UncertaintyScore.Zero(1.0, now),
Decay = decay ?? ObservationDecay.Fresh(now),
TrustScore = trustScore
};
}
}

View File

@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Result of determinization evaluation.
/// Combines observation state, uncertainty score, and guardrails.
/// </summary>
public sealed record DeterminizationResult
{
/// <summary>
/// Resulting observation state.
/// </summary>
[JsonPropertyName("state")]
public required ObservationState State { get; init; }
/// <summary>
/// Uncertainty score at evaluation time.
/// </summary>
[JsonPropertyName("uncertainty")]
public required UncertaintyScore Uncertainty { get; init; }
/// <summary>
/// Decay status at evaluation time.
/// </summary>
[JsonPropertyName("decay")]
public required ObservationDecay Decay { get; init; }
/// <summary>
/// Applied guardrails (if any).
/// </summary>
[JsonPropertyName("guardrails")]
public GuardRails? Guardrails { get; init; }
/// <summary>
/// Evaluation context.
/// </summary>
[JsonPropertyName("context")]
public required DeterminizationContext Context { get; init; }
/// <summary>
/// When this result was computed (UTC).
/// </summary>
[JsonPropertyName("evaluated_at")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Decision rationale.
/// </summary>
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Creates result for determined observation (low uncertainty).
/// </summary>
public static DeterminizationResult Determined(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.Determined,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.None(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = "Evidence sufficient for confident determination"
};
/// <summary>
/// Creates result for pending observation (high uncertainty).
/// </summary>
public static DeterminizationResult Pending(
UncertaintyScore uncertainty,
ObservationDecay decay,
GuardRails guardrails,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.PendingDeterminization,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = guardrails,
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Uncertainty ({uncertainty.Entropy:F2}) above threshold ({context.EntropyThreshold:F2})"
};
/// <summary>
/// Creates result for stale observation requiring refresh.
/// </summary>
public static DeterminizationResult Stale(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt) => new()
{
State = ObservationState.StaleRequiresRefresh,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.Strict(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Evidence decayed below threshold ({context.DecayThreshold:F2})"
};
/// <summary>
/// Creates result for disputed observation (conflicting signals).
/// </summary>
public static DeterminizationResult Disputed(
UncertaintyScore uncertainty,
ObservationDecay decay,
DeterminizationContext context,
DateTimeOffset evaluatedAt,
string reason) => new()
{
State = ObservationState.Disputed,
Uncertainty = uncertainty,
Decay = decay,
Guardrails = GuardRails.Strict(),
Context = context,
EvaluatedAt = evaluatedAt,
Rationale = $"Conflicting signals detected: {reason}"
};
}

View File

@@ -0,0 +1,124 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Guardrails policy configuration for uncertain observations.
/// Defines monitoring/restrictions when evidence is incomplete.
/// </summary>
public sealed record GuardRails
{
/// <summary>
/// Enable runtime monitoring.
/// </summary>
[JsonPropertyName("enable_monitoring")]
public required bool EnableMonitoring { get; init; }
/// <summary>
/// Restrict deployment to non-production environments.
/// </summary>
[JsonPropertyName("restrict_to_non_prod")]
public required bool RestrictToNonProd { get; init; }
/// <summary>
/// Require manual approval before deployment.
/// </summary>
[JsonPropertyName("require_approval")]
public required bool RequireApproval { get; init; }
/// <summary>
/// Schedule automatic re-evaluation after this duration.
/// </summary>
[JsonPropertyName("reeval_after")]
public TimeSpan? ReevalAfter { get; init; }
/// <summary>
/// Additional notes/rationale for guardrails.
/// </summary>
[JsonPropertyName("notes")]
public string? Notes { get; init; }
/// <summary>
/// Default guardrails instance with safe settings.
/// </summary>
public static GuardRails Default { get; } = new()
{
EnableMonitoring = true,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(7),
Notes = null
};
/// <summary>
/// Creates GuardRails with default safe settings.
/// </summary>
public static GuardRails CreateDefault() => new()
{
EnableMonitoring = true,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = TimeSpan.FromDays(7),
Notes = null
};
/// <summary>
/// Creates GuardRails for high-uncertainty observations.
/// </summary>
public static GuardRails Strict() => new()
{
EnableMonitoring = true,
RestrictToNonProd = true,
RequireApproval = true,
ReevalAfter = TimeSpan.FromDays(3),
Notes = "High uncertainty - strict guardrails applied"
};
/// <summary>
/// Creates GuardRails with no restrictions (all evidence present).
/// </summary>
public static GuardRails None() => new()
{
EnableMonitoring = false,
RestrictToNonProd = false,
RequireApproval = false,
ReevalAfter = null,
Notes = null
};
}
/// <summary>
/// Deployment environment classification.
/// </summary>
public enum DeploymentEnvironment
{
/// <summary>Development environment.</summary>
Development = 0,
/// <summary>Testing environment.</summary>
Testing = 1,
/// <summary>Staging/pre-production environment.</summary>
Staging = 2,
/// <summary>Production environment.</summary>
Production = 3
}
/// <summary>
/// Asset criticality classification.
/// </summary>
public enum AssetCriticality
{
/// <summary>Low criticality - minimal impact if compromised.</summary>
Low = 0,
/// <summary>Medium criticality - moderate impact.</summary>
Medium = 1,
/// <summary>High criticality - significant impact.</summary>
High = 2,
/// <summary>Critical - severe impact if compromised.</summary>
Critical = 3
}

View File

@@ -0,0 +1,123 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Per-observation decay configuration and computed state.
/// Tracks evidence staleness with configurable half-life.
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
/// </summary>
public sealed record ObservationDecay
{
/// <summary>
/// When the observation was first recorded (UTC).
/// </summary>
[JsonPropertyName("observed_at")]
public DateTimeOffset ObservedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// When the observation was last refreshed (UTC).
/// </summary>
[JsonPropertyName("refreshed_at")]
public DateTimeOffset RefreshedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Half-life in days.
/// Default: 14 days.
/// </summary>
[JsonPropertyName("half_life_days")]
public double HalfLifeDays { get; init; } = 14.0;
/// <summary>
/// Minimum confidence floor.
/// Default: 0.35 (consistent with FreshnessCalculator).
/// </summary>
[JsonPropertyName("floor")]
public double Floor { get; init; } = 0.35;
/// <summary>
/// Staleness threshold (0.0-1.0).
/// If decay multiplier drops below this, observation becomes stale.
/// Default: 0.50
/// </summary>
[JsonPropertyName("staleness_threshold")]
public double StalenessThreshold { get; init; } = 0.50;
/// <summary>
/// Last signal update time (alias for RefreshedAt for convenience).
/// </summary>
[JsonPropertyName("last_signal_update")]
public DateTimeOffset LastSignalUpdate { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Age in days since last refresh.
/// </summary>
[JsonPropertyName("age_days")]
public double AgeDays { get; init; }
/// <summary>
/// Pre-computed decay multiplier (0.0-1.0).
/// </summary>
[JsonPropertyName("decayed_multiplier")]
public double DecayedMultiplier { get; init; } = 1.0;
/// <summary>
/// Whether the observation is considered stale.
/// </summary>
[JsonPropertyName("is_stale")]
public bool IsStale { get; init; }
/// <summary>
/// Calculates the current decay multiplier.
/// </summary>
public double CalculateDecay(DateTimeOffset now)
{
var ageDays = (now - RefreshedAt).TotalDays;
if (ageDays <= 0)
return 1.0;
var decay = Math.Exp(-Math.Log(2) * ageDays / HalfLifeDays);
return Math.Max(Floor, decay);
}
/// <summary>
/// Returns true if the observation is stale (decay below threshold).
/// </summary>
public bool CheckIsStale(DateTimeOffset now) =>
CalculateDecay(now) < StalenessThreshold;
/// <summary>
/// Creates ObservationDecay with default settings.
/// </summary>
public static ObservationDecay Create(DateTimeOffset observedAt, DateTimeOffset? refreshedAt = null) => new()
{
ObservedAt = observedAt,
RefreshedAt = refreshedAt ?? observedAt,
HalfLifeDays = 14.0,
Floor = 0.35,
StalenessThreshold = 0.50
};
/// <summary>
/// Creates a fresh observation (just recorded).
/// </summary>
public static ObservationDecay Fresh(DateTimeOffset now) =>
Create(now, now);
/// <summary>
/// Creates ObservationDecay with custom settings.
/// </summary>
public static ObservationDecay WithSettings(
DateTimeOffset observedAt,
DateTimeOffset refreshedAt,
double halfLifeDays,
double floor,
double stalenessThreshold) => new()
{
ObservedAt = observedAt,
RefreshedAt = refreshedAt,
HalfLifeDays = halfLifeDays,
Floor = floor,
StalenessThreshold = stalenessThreshold
};
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Observation state for CVE tracking, independent of VEX status.
/// Allows a CVE to be "Affected" (VEX) but "PendingDeterminization" (observation).
/// </summary>
public enum ObservationState
{
/// <summary>
/// Initial state: CVE discovered but evidence incomplete.
/// Triggers guardrail-based policy evaluation.
/// </summary>
PendingDeterminization = 0,
/// <summary>
/// Evidence sufficient for confident determination.
/// Normal policy evaluation applies.
/// </summary>
Determined = 1,
/// <summary>
/// Multiple signals conflict (K4 Conflict state).
/// Requires human review regardless of confidence.
/// </summary>
Disputed = 2,
/// <summary>
/// Evidence decayed below threshold; needs refresh.
/// Auto-triggered when decay > threshold.
/// </summary>
StaleRequiresRefresh = 3,
/// <summary>
/// Manually flagged for review.
/// Bypasses automatic determinization.
/// </summary>
ManualReviewRequired = 4,
/// <summary>
/// CVE suppressed/ignored by policy exception.
/// Evidence tracking continues but decisions skip.
/// </summary>
Suppressed = 5
}

View File

@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Describes a missing signal that contributes to uncertainty.
/// </summary>
public sealed record SignalGap
{
/// <summary>
/// Signal name (e.g., "epss", "vex", "reachability").
/// </summary>
[JsonPropertyName("signal")]
public required string Signal { get; init; }
/// <summary>
/// Reason the signal is missing.
/// </summary>
[JsonPropertyName("reason")]
public required SignalGapReason Reason { get; init; }
/// <summary>
/// Prior assumption used in absence of signal.
/// </summary>
[JsonPropertyName("prior")]
public double? Prior { get; init; }
/// <summary>
/// Weight this signal contributes to total uncertainty.
/// </summary>
[JsonPropertyName("weight")]
public double Weight { get; init; }
/// <summary>
/// Human-readable description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Reason a signal is missing.
/// </summary>
public enum SignalGapReason
{
/// <summary>Signal not yet queried.</summary>
NotQueried,
/// <summary>Signal legitimately does not exist (e.g., EPSS not published yet).</summary>
NotAvailable,
/// <summary>Signal query failed due to external error.</summary>
QueryFailed,
/// <summary>Signal not applicable for this artifact type.</summary>
NotApplicable
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Query status for a signal.
/// Distinguishes between "not yet queried", "queried with result", and "query failed".
/// </summary>
public enum SignalQueryStatus
{
/// <summary>
/// Signal has not been queried yet.
/// Default state before any lookup attempt.
/// </summary>
NotQueried = 0,
/// <summary>
/// Signal query succeeded.
/// Value may be present or null (signal legitimately absent).
/// </summary>
Queried = 1,
/// <summary>
/// Signal query failed due to error (network, API timeout, etc.).
/// Value is null but reason is external failure, not absence.
/// </summary>
Failed = 2
}

View File

@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
using StellaOps.Policy.Determinization.Evidence;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Point-in-time snapshot of all signals for a CVE observation.
/// Used as input to uncertainty scoring.
/// </summary>
public sealed record SignalSnapshot
{
/// <summary>
/// CVE identifier.
/// </summary>
[JsonPropertyName("cve")]
public required string Cve { get; init; }
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// EPSS signal.
/// </summary>
[JsonPropertyName("epss")]
public required SignalState<EpssEvidence> Epss { get; init; }
/// <summary>
/// VEX signal.
/// </summary>
[JsonPropertyName("vex")]
public required SignalState<VexClaimSummary> Vex { get; init; }
/// <summary>
/// Reachability signal.
/// </summary>
[JsonPropertyName("reachability")]
public required SignalState<ReachabilityEvidence> Reachability { get; init; }
/// <summary>
/// Runtime signal.
/// </summary>
[JsonPropertyName("runtime")]
public required SignalState<RuntimeEvidence> Runtime { get; init; }
/// <summary>
/// Backport signal.
/// </summary>
[JsonPropertyName("backport")]
public required SignalState<BackportEvidence> Backport { get; init; }
/// <summary>
/// SBOM lineage signal.
/// </summary>
[JsonPropertyName("sbom")]
public required SignalState<SbomLineageEvidence> Sbom { get; init; }
/// <summary>
/// CVSS signal.
/// </summary>
[JsonPropertyName("cvss")]
public required SignalState<CvssEvidence> Cvss { get; init; }
/// <summary>
/// When this snapshot was captured (UTC).
/// </summary>
[JsonPropertyName("snapshot_at")]
public required DateTimeOffset SnapshotAt { get; init; }
/// <summary>
/// Creates an empty snapshot with all signals NotQueried.
/// </summary>
public static SignalSnapshot Empty(string cve, string purl, DateTimeOffset snapshotAt) => new()
{
Cve = cve,
Purl = purl,
Epss = SignalState<EpssEvidence>.NotQueried(),
Vex = SignalState<VexClaimSummary>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = snapshotAt
};
}

View File

@@ -0,0 +1,90 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Wraps a signal value with query status metadata.
/// Distinguishes between: not queried, queried with value, queried but absent, query failed.
/// </summary>
/// <typeparam name="T">The signal value type.</typeparam>
public sealed record SignalState<T>
{
/// <summary>
/// Query status for this signal.
/// </summary>
[JsonPropertyName("status")]
public required SignalQueryStatus Status { get; init; }
/// <summary>
/// Signal value, if queried and present.
/// Null can mean: not queried, legitimately absent, or query failed.
/// Check Status to disambiguate.
/// </summary>
[JsonPropertyName("value")]
public T? Value { get; init; }
/// <summary>
/// When this signal was last queried (UTC).
/// Null if never queried.
/// </summary>
[JsonPropertyName("queried_at")]
public DateTimeOffset? QueriedAt { get; init; }
/// <summary>
/// Error message if Status == Failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// Creates a SignalState in NotQueried status.
/// </summary>
public static SignalState<T> NotQueried() => new()
{
Status = SignalQueryStatus.NotQueried,
Value = default,
QueriedAt = null,
Error = null
};
/// <summary>
/// Creates a SignalState with a successful query result.
/// Value may be null if the signal legitimately does not exist.
/// </summary>
public static SignalState<T> Queried(T? value, DateTimeOffset queriedAt) => new()
{
Status = SignalQueryStatus.Queried,
Value = value,
QueriedAt = queriedAt,
Error = null
};
/// <summary>
/// Creates a SignalState representing a failed query.
/// </summary>
public static SignalState<T> Failed(string error, DateTimeOffset attemptedAt) => new()
{
Status = SignalQueryStatus.Failed,
Value = default,
QueriedAt = attemptedAt,
Error = error
};
/// <summary>
/// Returns true if the signal was queried and has a non-null value.
/// </summary>
[JsonIgnore]
public bool HasValue => Status == SignalQueryStatus.Queried && Value is not null;
/// <summary>
/// Returns true if the signal query failed.
/// </summary>
[JsonIgnore]
public bool IsFailed => Status == SignalQueryStatus.Failed;
/// <summary>
/// Returns true if the signal has not been queried yet.
/// </summary>
[JsonIgnore]
public bool IsNotQueried => Status == SignalQueryStatus.NotQueried;
}

View File

@@ -0,0 +1,129 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Determinization.Models;
/// <summary>
/// Uncertainty tier classification based on entropy score.
/// </summary>
public enum UncertaintyTier
{
/// <summary>
/// Very high confidence (entropy &lt; 0.2).
/// All or most key signals present and consistent.
/// </summary>
Minimal = 0,
/// <summary>
/// High confidence (entropy 0.2-0.4).
/// Most key signals present.
/// </summary>
Low = 1,
/// <summary>
/// Moderate confidence (entropy 0.4-0.6).
/// Some signals missing or conflicting.
/// </summary>
Moderate = 2,
/// <summary>
/// Low confidence (entropy 0.6-0.8).
/// Many signals missing or conflicting.
/// </summary>
High = 3,
/// <summary>
/// Very low confidence (entropy &gt;= 0.8).
/// Critical signals missing or heavily conflicting.
/// </summary>
Critical = 4
}
/// <summary>
/// Quantifies knowledge completeness (not code entropy).
/// Calculated from signal presence/absence weighted by importance.
/// Formula: entropy = 1 - (sum of weighted present signals / max possible weight)
/// </summary>
public sealed record UncertaintyScore
{
/// <summary>
/// Entropy value [0.0, 1.0].
/// 0 = complete knowledge, 1 = complete uncertainty.
/// </summary>
[JsonPropertyName("entropy")]
public required double Entropy { get; init; }
/// <summary>
/// Uncertainty tier derived from entropy.
/// </summary>
[JsonPropertyName("tier")]
public required UncertaintyTier Tier { get; init; }
/// <summary>
/// Missing signals contributing to uncertainty.
/// </summary>
[JsonPropertyName("gaps")]
public required IReadOnlyList<SignalGap> Gaps { get; init; }
/// <summary>
/// Total weight of present signals.
/// </summary>
[JsonPropertyName("present_weight")]
public required double PresentWeight { get; init; }
/// <summary>
/// Maximum possible weight (sum of all signal weights).
/// </summary>
[JsonPropertyName("max_weight")]
public required double MaxWeight { get; init; }
/// <summary>
/// When this score was calculated (UTC).
/// </summary>
[JsonPropertyName("calculated_at")]
public required DateTimeOffset CalculatedAt { get; init; }
/// <summary>
/// Completeness ratio (present_weight / max_weight).
/// </summary>
[JsonIgnore]
public double Completeness => MaxWeight > 0 ? PresentWeight / MaxWeight : 0.0;
/// <summary>
/// Creates an UncertaintyScore with calculated tier.
/// </summary>
public static UncertaintyScore Create(
double entropy,
IReadOnlyList<SignalGap> gaps,
double presentWeight,
double maxWeight,
DateTimeOffset calculatedAt)
{
if (entropy < 0.0 || entropy > 1.0)
throw new ArgumentOutOfRangeException(nameof(entropy), "Entropy must be in [0.0, 1.0]");
var tier = entropy switch
{
< 0.2 => UncertaintyTier.Minimal,
< 0.4 => UncertaintyTier.Low,
< 0.6 => UncertaintyTier.Moderate,
< 0.8 => UncertaintyTier.High,
_ => UncertaintyTier.Critical
};
return new UncertaintyScore
{
Entropy = entropy,
Tier = tier,
Gaps = gaps,
PresentWeight = presentWeight,
MaxWeight = maxWeight,
CalculatedAt = calculatedAt
};
}
/// <summary>
/// Creates a zero-entropy score (complete knowledge).
/// </summary>
public static UncertaintyScore Zero(double maxWeight, DateTimeOffset calculatedAt) =>
Create(0.0, Array.Empty<SignalGap>(), maxWeight, maxWeight, calculatedAt);
}

View File

@@ -0,0 +1,75 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates decayed confidence scores using exponential half-life decay.
/// </summary>
public sealed class DecayedConfidenceCalculator : IDecayedConfidenceCalculator
{
private static readonly Meter Meter = new("StellaOps.Policy.Determinization");
private static readonly Histogram<double> DecayMultiplierHistogram = Meter.CreateHistogram<double>(
"stellaops_determinization_decay_multiplier",
unit: "ratio",
description: "Confidence decay multiplier based on observation age and half-life");
private readonly ILogger<DecayedConfidenceCalculator> _logger;
public DecayedConfidenceCalculator(ILogger<DecayedConfidenceCalculator> logger)
{
_logger = logger;
}
public double Calculate(
double baseConfidence,
double ageDays,
double halfLifeDays = 14.0,
double floor = 0.1)
{
if (baseConfidence < 0.0 || baseConfidence > 1.0)
throw new ArgumentOutOfRangeException(nameof(baseConfidence), "Must be between 0.0 and 1.0");
if (ageDays < 0.0)
throw new ArgumentOutOfRangeException(nameof(ageDays), "Cannot be negative");
if (halfLifeDays <= 0.0)
throw new ArgumentOutOfRangeException(nameof(halfLifeDays), "Must be positive");
if (floor < 0.0 || floor > 1.0)
throw new ArgumentOutOfRangeException(nameof(floor), "Must be between 0.0 and 1.0");
var decayFactor = CalculateDecayFactor(ageDays, halfLifeDays);
var decayed = baseConfidence * decayFactor;
var result = Math.Max(floor, decayed);
_logger.LogDebug(
"Decayed confidence from {Base:F4} to {Result:F4} (age={AgeDays:F2}d, half-life={HalfLife:F2}d, floor={Floor:F2})",
baseConfidence,
result,
ageDays,
halfLifeDays,
floor);
// Emit metric for decay multiplier (factor before floor is applied)
DecayMultiplierHistogram.Record(decayFactor,
new KeyValuePair<string, object?>("half_life_days", halfLifeDays),
new KeyValuePair<string, object?>("age_days", ageDays));
return result;
}
public double CalculateDecayFactor(double ageDays, double halfLifeDays = 14.0)
{
if (ageDays < 0.0)
throw new ArgumentOutOfRangeException(nameof(ageDays), "Cannot be negative");
if (halfLifeDays <= 0.0)
throw new ArgumentOutOfRangeException(nameof(halfLifeDays), "Must be positive");
// Formula: exp(-ln(2) * age_days / half_life_days)
var exponent = -Math.Log(2.0) * ageDays / halfLifeDays;
var factor = Math.Exp(exponent);
return Math.Clamp(factor, 0.0, 1.0);
}
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates decayed confidence scores using exponential half-life decay.
/// </summary>
public interface IDecayedConfidenceCalculator
{
/// <summary>
/// Calculate decayed confidence from observation age.
/// Formula: decayed = max(floor, exp(-ln(2) * age_days / half_life_days))
/// </summary>
/// <param name="baseConfidence">Original confidence score (0.0-1.0)</param>
/// <param name="ageDays">Age of observation in days</param>
/// <param name="halfLifeDays">Half-life period (default: 14 days)</param>
/// <param name="floor">Minimum confidence floor (default: 0.1)</param>
/// <returns>Decayed confidence score</returns>
double Calculate(
double baseConfidence,
double ageDays,
double halfLifeDays = 14.0,
double floor = 0.1);
/// <summary>
/// Calculate decay factor only (without applying to base confidence).
/// </summary>
double CalculateDecayFactor(double ageDays, double halfLifeDays = 14.0);
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates uncertainty scores based on signal completeness (entropy).
/// </summary>
public interface IUncertaintyScoreCalculator
{
/// <summary>
/// Calculate uncertainty score from a signal snapshot.
/// Formula: entropy = 1 - (weighted_present_signals / max_possible_weight)
/// </summary>
/// <param name="snapshot">Signal snapshot containing presence indicators</param>
/// <param name="weights">Signal weights (optional, uses defaults if null)</param>
/// <returns>Uncertainty score with tier classification</returns>
UncertaintyScore Calculate(SignalSnapshot snapshot, SignalWeights? weights = null);
/// <summary>
/// Calculate raw entropy value (0.0 = complete knowledge, 1.0 = no knowledge).
/// </summary>
double CalculateEntropy(SignalSnapshot snapshot, SignalWeights? weights = null);
}

View File

@@ -0,0 +1,40 @@
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Prior distribution for missing signals (Bayesian approach).
/// </summary>
public sealed record PriorDistribution
{
/// <summary>Conservative prior: assume affected until proven otherwise.</summary>
public static readonly PriorDistribution Conservative = new()
{
AffectedProbability = 0.70,
NotAffectedProbability = 0.20,
UnknownProbability = 0.10
};
/// <summary>Neutral prior: equal weighting for affected/not-affected.</summary>
public static readonly PriorDistribution Neutral = new()
{
AffectedProbability = 0.40,
NotAffectedProbability = 0.40,
UnknownProbability = 0.20
};
/// <summary>Probability of "Affected" status (default: 0.70 conservative).</summary>
public required double AffectedProbability { get; init; }
/// <summary>Probability of "Not Affected" status (default: 0.20).</summary>
public required double NotAffectedProbability { get; init; }
/// <summary>Probability of "Unknown" status (default: 0.10).</summary>
public required double UnknownProbability { get; init; }
/// <summary>Sum of all probabilities (should equal 1.0).</summary>
public double Total =>
AffectedProbability + NotAffectedProbability + UnknownProbability;
/// <summary>Validates that probabilities sum to approximately 1.0.</summary>
public bool IsNormalized(double tolerance = 0.001) =>
Math.Abs(Total - 1.0) < tolerance;
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Configurable signal weights for entropy calculation.
/// </summary>
public sealed record SignalWeights
{
/// <summary>Default weights following advisory recommendations.</summary>
public static readonly SignalWeights Default = new()
{
VexWeight = 0.25,
EpssWeight = 0.15,
ReachabilityWeight = 0.25,
RuntimeWeight = 0.15,
BackportWeight = 0.10,
SbomLineageWeight = 0.10
};
/// <summary>Weight for VEX claim signals (default: 0.25).</summary>
public required double VexWeight { get; init; }
/// <summary>Weight for EPSS signals (default: 0.15).</summary>
public required double EpssWeight { get; init; }
/// <summary>Weight for Reachability signals (default: 0.25).</summary>
public required double ReachabilityWeight { get; init; }
/// <summary>Weight for Runtime detection signals (default: 0.15).</summary>
public required double RuntimeWeight { get; init; }
/// <summary>Weight for Backport evidence signals (default: 0.10).</summary>
public required double BackportWeight { get; init; }
/// <summary>Weight for SBOM lineage signals (default: 0.10).</summary>
public required double SbomLineageWeight { get; init; }
/// <summary>Sum of all weights (should equal 1.0 for normalized calculations).</summary>
public double TotalWeight =>
VexWeight + EpssWeight + ReachabilityWeight +
RuntimeWeight + BackportWeight + SbomLineageWeight;
/// <summary>Validates that weights sum to approximately 1.0.</summary>
public bool IsNormalized(double tolerance = 0.001) =>
Math.Abs(TotalWeight - 1.0) < tolerance;
}

View File

@@ -0,0 +1,125 @@
using StellaOps.Policy.Determinization.Evidence;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Aggregates individual signal scores into a final trust/confidence score.
/// </summary>
public sealed class TrustScoreAggregator
{
private readonly ILogger<TrustScoreAggregator> _logger;
public TrustScoreAggregator(ILogger<TrustScoreAggregator> logger)
{
_logger = logger;
}
/// <summary>
/// Aggregate signal scores using weighted average with uncertainty penalty.
/// </summary>
/// <param name="snapshot">Signal snapshot with all available signals</param>
/// <param name="uncertaintyScore">Uncertainty score from entropy calculation</param>
/// <param name="weights">Signal weights (optional)</param>
/// <returns>Aggregated trust score (0.0-1.0)</returns>
public double Aggregate(
SignalSnapshot snapshot,
UncertaintyScore uncertaintyScore,
SignalWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(uncertaintyScore);
var effectiveWeights = weights ?? SignalWeights.Default;
// Calculate weighted sum of present signals
var weightedSum = 0.0;
var totalWeight = 0.0;
var presentCount = 0;
if (!snapshot.Vex.IsNotQueried && snapshot.Vex.Value is not null)
{
var score = CalculateVexScore(snapshot.Vex.Value);
weightedSum += score * effectiveWeights.VexWeight;
totalWeight += effectiveWeights.VexWeight;
presentCount++;
}
if (!snapshot.Epss.IsNotQueried && snapshot.Epss.Value is not null)
{
var score = snapshot.Epss.Value.Epss; // EPSS score is the risk score
weightedSum += score * effectiveWeights.EpssWeight;
totalWeight += effectiveWeights.EpssWeight;
presentCount++;
}
if (!snapshot.Reachability.IsNotQueried && snapshot.Reachability.Value is not null)
{
var score = snapshot.Reachability.Value.Status == ReachabilityStatus.Reachable ? 1.0 : 0.0;
weightedSum += score * effectiveWeights.ReachabilityWeight;
totalWeight += effectiveWeights.ReachabilityWeight;
presentCount++;
}
if (!snapshot.Runtime.IsNotQueried && snapshot.Runtime.Value is not null)
{
var score = snapshot.Runtime.Value.Detected ? 1.0 : 0.0;
weightedSum += score * effectiveWeights.RuntimeWeight;
totalWeight += effectiveWeights.RuntimeWeight;
presentCount++;
}
if (!snapshot.Backport.IsNotQueried && snapshot.Backport.Value is not null)
{
var score = snapshot.Backport.Value.Detected ? 0.0 : 1.0; // Inverted: backport = lower risk
weightedSum += score * effectiveWeights.BackportWeight;
totalWeight += effectiveWeights.BackportWeight;
presentCount++;
}
if (!snapshot.Sbom.IsNotQueried && snapshot.Sbom.Value is not null)
{
// For now, just check if SBOM exists (conservative scoring)
var score = 0.5; // Neutral score for SBOM lineage
weightedSum += score * effectiveWeights.SbomLineageWeight;
totalWeight += effectiveWeights.SbomLineageWeight;
presentCount++;
}
// If no signals present, return 0.5 (neutral) penalized by uncertainty
if (totalWeight == 0.0)
{
_logger.LogWarning("No signals present for aggregation; returning neutral score penalized by uncertainty");
return 0.5 * (1.0 - uncertaintyScore.Entropy);
}
// Weighted average
var baseScore = weightedSum / totalWeight;
// Apply uncertainty penalty: lower confidence when entropy is high
var confidenceFactor = 1.0 - uncertaintyScore.Entropy;
var adjustedScore = baseScore * confidenceFactor;
_logger.LogDebug(
"Aggregated trust score {Score:F4} from {PresentSignals} signals (base={Base:F4}, confidence={Confidence:F4})",
adjustedScore,
presentCount,
baseScore,
confidenceFactor);
return Math.Clamp(adjustedScore, 0.0, 1.0);
}
private static double CalculateVexScore(VexClaimSummary vex)
{
// Map VEX status to risk score
return vex.Status.ToLowerInvariant() switch
{
"affected" => 1.0,
"under_investigation" => 0.7,
"not_affected" => 0.0,
"fixed" => 0.1,
_ => 0.5
};
}
}

View File

@@ -0,0 +1,103 @@
using System.Diagnostics.Metrics;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy.Determinization.Scoring;
/// <summary>
/// Calculates uncertainty scores based on signal completeness using entropy formula.
/// </summary>
public sealed class UncertaintyScoreCalculator : IUncertaintyScoreCalculator
{
private static readonly Meter Meter = new("StellaOps.Policy.Determinization");
private static readonly Histogram<double> EntropyHistogram = Meter.CreateHistogram<double>(
"stellaops_determinization_uncertainty_entropy",
unit: "ratio",
description: "Uncertainty entropy score (0.0 = complete knowledge, 1.0 = no knowledge)");
private readonly ILogger<UncertaintyScoreCalculator> _logger;
public UncertaintyScoreCalculator(ILogger<UncertaintyScoreCalculator> logger)
{
_logger = logger;
}
public UncertaintyScore Calculate(SignalSnapshot snapshot, SignalWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
var effectiveWeights = weights ?? SignalWeights.Default;
var entropy = CalculateEntropy(snapshot, effectiveWeights);
// Calculate present weight
var presentWeight = effectiveWeights.TotalWeight * (1.0 - entropy);
// Calculate gaps (missing signals)
var gaps = new List<SignalGap>();
if (snapshot.Vex.IsNotQueried)
gaps.Add(new SignalGap { Signal = "VEX", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.VexWeight });
if (snapshot.Epss.IsNotQueried)
gaps.Add(new SignalGap { Signal = "EPSS", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.EpssWeight });
if (snapshot.Reachability.IsNotQueried)
gaps.Add(new SignalGap { Signal = "Reachability", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.ReachabilityWeight });
if (snapshot.Runtime.IsNotQueried)
gaps.Add(new SignalGap { Signal = "Runtime", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.RuntimeWeight });
if (snapshot.Backport.IsNotQueried)
gaps.Add(new SignalGap { Signal = "Backport", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.BackportWeight });
if (snapshot.Sbom.IsNotQueried)
gaps.Add(new SignalGap { Signal = "SBOMLineage", Reason = SignalGapReason.NotQueried, Weight = effectiveWeights.SbomLineageWeight });
return UncertaintyScore.Create(
entropy,
gaps,
presentWeight,
effectiveWeights.TotalWeight,
snapshot.SnapshotAt);
}
public double CalculateEntropy(SignalSnapshot snapshot, SignalWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(snapshot);
var effectiveWeights = weights ?? SignalWeights.Default;
// Calculate total weight of present signals
var presentWeight = 0.0;
if (!snapshot.Vex.IsNotQueried)
presentWeight += effectiveWeights.VexWeight;
if (!snapshot.Epss.IsNotQueried)
presentWeight += effectiveWeights.EpssWeight;
if (!snapshot.Reachability.IsNotQueried)
presentWeight += effectiveWeights.ReachabilityWeight;
if (!snapshot.Runtime.IsNotQueried)
presentWeight += effectiveWeights.RuntimeWeight;
if (!snapshot.Backport.IsNotQueried)
presentWeight += effectiveWeights.BackportWeight;
if (!snapshot.Sbom.IsNotQueried)
presentWeight += effectiveWeights.SbomLineageWeight;
// Entropy = 1 - (present / total_possible)
var totalPossibleWeight = effectiveWeights.TotalWeight;
var entropy = 1.0 - (presentWeight / totalPossibleWeight);
_logger.LogDebug(
"Calculated entropy {Entropy:F4} from {PresentWeight:F2}/{TotalWeight:F2} signal weight",
entropy,
presentWeight,
totalPossibleWeight);
var clampedEntropy = Math.Clamp(entropy, 0.0, 1.0);
// Emit metric
EntropyHistogram.Record(clampedEntropy,
new KeyValuePair<string, object?>("cve", snapshot.Cve),
new KeyValuePair<string, object?>("purl", snapshot.Purl));
return clampedEntropy;
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Determinization.Scoring;
namespace StellaOps.Policy.Determinization;
/// <summary>
/// Service registration for Determinization subsystem.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers determinization services with default options.
/// </summary>
/// <param name="services">Service collection</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddDeterminization(this IServiceCollection services)
{
return services.AddDeterminization(_ => { });
}
/// <summary>
/// Registers determinization services with the DI container.
/// </summary>
/// <param name="services">Service collection</param>
/// <param name="configuration">Configuration root (for options binding)</param>
/// <returns>Service collection for chaining</returns>
public static IServiceCollection AddDeterminization(
this IServiceCollection services,
IConfiguration configuration)
{
// Register options
services.AddOptions<DeterminizationOptions>()
.Bind(configuration.GetSection(DeterminizationOptions.SectionName))
.ValidateOnStart();
// Register scoring calculators (both interface and concrete for flexibility)
services.TryAddSingleton<UncertaintyScoreCalculator>();
services.TryAddSingleton<IUncertaintyScoreCalculator>(sp => sp.GetRequiredService<UncertaintyScoreCalculator>());
services.TryAddSingleton<DecayedConfidenceCalculator>();
services.TryAddSingleton<IDecayedConfidenceCalculator>(sp => sp.GetRequiredService<DecayedConfidenceCalculator>());
services.TryAddSingleton<TrustScoreAggregator>();
return services;
}
/// <summary>
/// Registers determinization services with custom options.
/// </summary>
public static IServiceCollection AddDeterminization(
this IServiceCollection services,
Action<DeterminizationOptions> configureOptions)
{
services.AddOptions<DeterminizationOptions>()
.Configure(configureOptions)
.ValidateOnStart();
// Register scoring calculators (both interface and concrete for flexibility)
services.TryAddSingleton<UncertaintyScoreCalculator>();
services.TryAddSingleton<IUncertaintyScoreCalculator>(sp => sp.GetRequiredService<UncertaintyScoreCalculator>());
services.TryAddSingleton<DecayedConfidenceCalculator>();
services.TryAddSingleton<IDecayedConfidenceCalculator>(sp => sp.GetRequiredService<DecayedConfidenceCalculator>());
services.TryAddSingleton<TrustScoreAggregator>();
return services;
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,5 @@
using System.Collections.Immutable;
using StellaOps.Determinism.Abstractions;
using System.Globalization;
namespace StellaOps.Policy.Exceptions.Models;
@@ -121,17 +121,15 @@ public sealed record ExceptionEvent
public static ExceptionEvent ForCreated(
string exceptionId,
string actorId,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? clientInfo = null) => new()
{
EventId = guidProvider.NewGuid(),
EventId = Guid.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = 1,
EventType = ExceptionEventType.Created,
ActorId = actorId,
OccurredAt = timeProvider.GetUtcNow(),
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = null,
NewStatus = ExceptionStatus.Proposed,
NewVersion = 1,
@@ -147,17 +145,15 @@ public sealed record ExceptionEvent
int sequenceNumber,
string actorId,
int newVersion,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? clientInfo = null) => new()
{
EventId = guidProvider.NewGuid(),
EventId = Guid.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Approved,
ActorId = actorId,
OccurredAt = timeProvider.GetUtcNow(),
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = ExceptionStatus.Proposed,
NewStatus = ExceptionStatus.Approved,
NewVersion = newVersion,
@@ -174,17 +170,15 @@ public sealed record ExceptionEvent
string actorId,
int newVersion,
ExceptionStatus previousStatus,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? clientInfo = null) => new()
{
EventId = guidProvider.NewGuid(),
EventId = Guid.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Activated,
ActorId = actorId,
OccurredAt = timeProvider.GetUtcNow(),
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = previousStatus,
NewStatus = ExceptionStatus.Active,
NewVersion = newVersion,
@@ -202,16 +196,14 @@ public sealed record ExceptionEvent
int newVersion,
ExceptionStatus previousStatus,
string reason,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? clientInfo = null) => new()
{
EventId = guidProvider.NewGuid(),
EventId = Guid.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Revoked,
ActorId = actorId,
OccurredAt = timeProvider.GetUtcNow(),
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = previousStatus,
NewStatus = ExceptionStatus.Revoked,
NewVersion = newVersion,
@@ -226,16 +218,14 @@ public sealed record ExceptionEvent
public static ExceptionEvent ForExpired(
string exceptionId,
int sequenceNumber,
int newVersion,
TimeProvider timeProvider,
IGuidProvider guidProvider) => new()
int newVersion) => new()
{
EventId = guidProvider.NewGuid(),
EventId = Guid.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Expired,
ActorId = "system",
OccurredAt = timeProvider.GetUtcNow(),
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = ExceptionStatus.Active,
NewStatus = ExceptionStatus.Expired,
NewVersion = newVersion,
@@ -252,24 +242,22 @@ public sealed record ExceptionEvent
int newVersion,
DateTimeOffset previousExpiry,
DateTimeOffset newExpiry,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? reason = null,
string? clientInfo = null) => new()
{
EventId = guidProvider.NewGuid(),
EventId = Guid.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Extended,
ActorId = actorId,
OccurredAt = timeProvider.GetUtcNow(),
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = ExceptionStatus.Active,
NewStatus = ExceptionStatus.Active,
NewVersion = newVersion,
Description = reason ?? $"Exception extended from {previousExpiry:O} to {newExpiry:O}",
Details = ImmutableDictionary<string, string>.Empty
.Add("previous_expiry", previousExpiry.ToString("O"))
.Add("new_expiry", newExpiry.ToString("O")),
.Add("previous_expiry", previousExpiry.ToString("O", CultureInfo.InvariantCulture))
.Add("new_expiry", newExpiry.ToString("O", CultureInfo.InvariantCulture)),
ClientInfo = clientInfo
};
}

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0443-M | DONE | Maintainability audit for StellaOps.Policy.Exceptions. |
| AUDIT-0443-T | DONE | Test coverage audit for StellaOps.Policy.Exceptions. |
| AUDIT-0443-A | TODO | APPLY pending approval for StellaOps.Policy.Exceptions. |
| AUDIT-0443-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Exceptions. |
| AUDIT-0443-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Exceptions. |
| AUDIT-0443-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -0,0 +1,8 @@
global using System;
global using System.Collections.Generic;
global using System.Collections.Immutable;
global using System.Linq;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using Microsoft.Extensions.Logging;

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Renders verdict rationales in multiple formats.
/// </summary>
public interface IVerdictRationaleRenderer
{
/// <summary>
/// Renders a complete verdict rationale from verdict components.
/// </summary>
VerdictRationale Render(VerdictRationaleInput input);
/// <summary>
/// Renders rationale as plain text (4-line format).
/// </summary>
string RenderPlainText(VerdictRationale rationale);
/// <summary>
/// Renders rationale as Markdown.
/// </summary>
string RenderMarkdown(VerdictRationale rationale);
/// <summary>
/// Renders rationale as canonical JSON (RFC 8785).
/// </summary>
string RenderJson(VerdictRationale rationale);
}
/// <summary>
/// Input for verdict rationale rendering.
/// </summary>
public sealed record VerdictRationaleInput
{
public required VerdictReference VerdictRef { get; init; }
public required string Cve { get; init; }
public required ComponentIdentity Component { get; init; }
public ReachabilityDetail? Reachability { get; init; }
public required string PolicyClauseId { get; init; }
public required string PolicyRuleDescription { get; init; }
public required IReadOnlyList<string> PolicyConditions { get; init; }
public AttestationReference? PathWitness { get; init; }
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
public AttestationReference? Provenance { get; init; }
public required string Verdict { get; init; }
public double? Score { get; init; }
public required string Recommendation { get; init; }
public MitigationGuidance? Mitigation { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required string VerdictDigest { get; init; }
public string? PolicyDigest { get; init; }
public string? EvidenceDigest { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Policy.Explainability;
public static class ExplainabilityServiceCollectionExtensions
{
public static IServiceCollection AddVerdictExplainability(this IServiceCollection services)
{
services.AddSingleton<IVerdictRationaleRenderer, VerdictRationaleRenderer>();
return services;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,197 @@
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Structured verdict rationale following the 4-line template.
/// Line 1: Evidence summary
/// Line 2: Policy clause that triggered the decision
/// Line 3: Attestations and proofs supporting the verdict
/// Line 4: Final decision with score and recommendation
/// </summary>
public sealed record VerdictRationale
{
/// <summary>Schema version for forward compatibility.</summary>
[JsonPropertyName("schema_version")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>Unique rationale ID (content-addressed).</summary>
[JsonPropertyName("rationale_id")]
public required string RationaleId { get; init; }
/// <summary>Reference to the verdict being explained.</summary>
[JsonPropertyName("verdict_ref")]
public required VerdictReference VerdictRef { get; init; }
/// <summary>Line 1: Evidence summary.</summary>
[JsonPropertyName("evidence")]
public required RationaleEvidence Evidence { get; init; }
/// <summary>Line 2: Policy clause that triggered the decision.</summary>
[JsonPropertyName("policy_clause")]
public required RationalePolicyClause PolicyClause { get; init; }
/// <summary>Line 3: Attestations and proofs supporting the verdict.</summary>
[JsonPropertyName("attestations")]
public required RationaleAttestations Attestations { get; init; }
/// <summary>Line 4: Final decision with score and recommendation.</summary>
[JsonPropertyName("decision")]
public required RationaleDecision Decision { get; init; }
/// <summary>Generation timestamp (UTC).</summary>
[JsonPropertyName("generated_at")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Input digests for reproducibility.</summary>
[JsonPropertyName("input_digests")]
public required RationaleInputDigests InputDigests { get; init; }
}
/// <summary>Reference to the verdict being explained.</summary>
public sealed record VerdictReference
{
[JsonPropertyName("attestation_id")]
public required string AttestationId { get; init; }
[JsonPropertyName("artifact_digest")]
public required string ArtifactDigest { get; init; }
[JsonPropertyName("policy_id")]
public required string PolicyId { get; init; }
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("component_purl")]
public string? ComponentPurl { get; init; }
}
/// <summary>Line 1: Evidence summary.</summary>
public sealed record RationaleEvidence
{
[JsonPropertyName("cve")]
public required string Cve { get; init; }
[JsonPropertyName("component")]
public required ComponentIdentity Component { get; init; }
[JsonPropertyName("reachability")]
public ReachabilityDetail? Reachability { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
public sealed record ComponentIdentity
{
[JsonPropertyName("purl")]
public required string Purl { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("ecosystem")]
public string? Ecosystem { get; init; }
}
public sealed record ReachabilityDetail
{
[JsonPropertyName("vulnerable_function")]
public string? VulnerableFunction { get; init; }
[JsonPropertyName("entry_point")]
public string? EntryPoint { get; init; }
[JsonPropertyName("path_summary")]
public string? PathSummary { get; init; }
}
/// <summary>Line 2: Policy clause reference.</summary>
public sealed record RationalePolicyClause
{
[JsonPropertyName("clause_id")]
public required string ClauseId { get; init; }
[JsonPropertyName("rule_description")]
public required string RuleDescription { get; init; }
[JsonPropertyName("conditions")]
public required IReadOnlyList<string> Conditions { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
/// <summary>Line 3: Attestations and proofs.</summary>
public sealed record RationaleAttestations
{
[JsonPropertyName("path_witness")]
public AttestationReference? PathWitness { get; init; }
[JsonPropertyName("vex_statements")]
public IReadOnlyList<AttestationReference>? VexStatements { get; init; }
[JsonPropertyName("provenance")]
public AttestationReference? Provenance { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
public sealed record AttestationReference
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("summary")]
public string? Summary { get; init; }
}
/// <summary>Line 4: Final decision.</summary>
public sealed record RationaleDecision
{
[JsonPropertyName("verdict")]
public required string Verdict { get; init; }
[JsonPropertyName("score")]
public double? Score { get; init; }
[JsonPropertyName("recommendation")]
public required string Recommendation { get; init; }
[JsonPropertyName("mitigation")]
public MitigationGuidance? Mitigation { get; init; }
[JsonPropertyName("formatted_text")]
public required string FormattedText { get; init; }
}
public sealed record MitigationGuidance
{
[JsonPropertyName("action")]
public required string Action { get; init; }
[JsonPropertyName("details")]
public string? Details { get; init; }
}
/// <summary>Input digests for reproducibility.</summary>
public sealed record RationaleInputDigests
{
[JsonPropertyName("verdict_digest")]
public required string VerdictDigest { get; init; }
[JsonPropertyName("policy_digest")]
public string? PolicyDigest { get; init; }
[JsonPropertyName("evidence_digest")]
public string? EvidenceDigest { get; init; }
}

View File

@@ -0,0 +1,200 @@
using System.Security.Cryptography;
using StellaOps.Canonical.Json;
namespace StellaOps.Policy.Explainability;
/// <summary>
/// Renders verdict rationales in multiple formats following the 4-line template.
/// </summary>
public sealed class VerdictRationaleRenderer : IVerdictRationaleRenderer
{
private readonly ILogger<VerdictRationaleRenderer> _logger;
public VerdictRationaleRenderer(ILogger<VerdictRationaleRenderer> logger)
{
_logger = logger;
}
public VerdictRationale Render(VerdictRationaleInput input)
{
var evidence = RenderEvidence(input);
var policyClause = RenderPolicyClause(input);
var attestations = RenderAttestations(input);
var decision = RenderDecision(input);
var inputDigests = new RationaleInputDigests
{
VerdictDigest = input.VerdictDigest,
PolicyDigest = input.PolicyDigest,
EvidenceDigest = input.EvidenceDigest
};
var rationale = new VerdictRationale
{
RationaleId = string.Empty, // Will be computed below
VerdictRef = input.VerdictRef,
Evidence = evidence,
PolicyClause = policyClause,
Attestations = attestations,
Decision = decision,
GeneratedAt = input.GeneratedAt,
InputDigests = inputDigests
};
// Compute content-addressed ID
var rationaleId = ComputeRationaleId(rationale);
return rationale with { RationaleId = rationaleId };
}
public string RenderPlainText(VerdictRationale rationale)
{
var sb = new StringBuilder();
sb.AppendLine(rationale.Evidence.FormattedText);
sb.AppendLine(rationale.PolicyClause.FormattedText);
sb.AppendLine(rationale.Attestations.FormattedText);
sb.AppendLine(rationale.Decision.FormattedText);
return sb.ToString();
}
public string RenderMarkdown(VerdictRationale rationale)
{
var sb = new StringBuilder();
sb.AppendLine($"## Verdict Rationale: {rationale.Evidence.Cve}");
sb.AppendLine();
sb.AppendLine("### Evidence");
sb.AppendLine(rationale.Evidence.FormattedText);
sb.AppendLine();
sb.AppendLine("### Policy Clause");
sb.AppendLine(rationale.PolicyClause.FormattedText);
sb.AppendLine();
sb.AppendLine("### Attestations");
sb.AppendLine(rationale.Attestations.FormattedText);
sb.AppendLine();
sb.AppendLine("### Decision");
sb.AppendLine(rationale.Decision.FormattedText);
sb.AppendLine();
sb.AppendLine($"*Rationale ID: `{rationale.RationaleId}`*");
return sb.ToString();
}
public string RenderJson(VerdictRationale rationale)
{
return CanonJson.Serialize(rationale);
}
private RationaleEvidence RenderEvidence(VerdictRationaleInput input)
{
var text = new StringBuilder();
text.Append($"CVE-{input.Cve.Replace("CVE-", "")} in `{input.Component.Name ?? input.Component.Purl}` {input.Component.Version}");
if (input.Reachability != null)
{
text.Append($"; symbol `{input.Reachability.VulnerableFunction}` reachable from `{input.Reachability.EntryPoint}`");
if (!string.IsNullOrEmpty(input.Reachability.PathSummary))
{
text.Append($" ({input.Reachability.PathSummary})");
}
}
text.Append('.');
return new RationaleEvidence
{
Cve = input.Cve,
Component = input.Component,
Reachability = input.Reachability,
FormattedText = text.ToString()
};
}
private RationalePolicyClause RenderPolicyClause(VerdictRationaleInput input)
{
var text = $"Policy {input.PolicyClauseId}: {input.PolicyRuleDescription}";
if (input.PolicyConditions.Any())
{
text += $" ({string.Join(", ", input.PolicyConditions)})";
}
text += ".";
return new RationalePolicyClause
{
ClauseId = input.PolicyClauseId,
RuleDescription = input.PolicyRuleDescription,
Conditions = input.PolicyConditions,
FormattedText = text
};
}
private RationaleAttestations RenderAttestations(VerdictRationaleInput input)
{
var parts = new List<string>();
if (input.PathWitness != null)
{
parts.Add($"Path witness: {input.PathWitness.Summary ?? input.PathWitness.Id}");
}
if (input.VexStatements?.Any() == true)
{
var vexSummary = string.Join(", ", input.VexStatements.Select(v => v.Summary ?? v.Id));
parts.Add($"VEX statements: {vexSummary}");
}
if (input.Provenance != null)
{
parts.Add($"Provenance: {input.Provenance.Summary ?? input.Provenance.Id}");
}
var text = parts.Any()
? string.Join("; ", parts) + "."
: "No attestations available.";
return new RationaleAttestations
{
PathWitness = input.PathWitness,
VexStatements = input.VexStatements,
Provenance = input.Provenance,
FormattedText = text
};
}
private RationaleDecision RenderDecision(VerdictRationaleInput input)
{
var text = new StringBuilder();
text.Append($"{input.Verdict}");
if (input.Score.HasValue)
{
text.Append($" (score {input.Score.Value:F2})");
}
text.Append($". {input.Recommendation}");
if (input.Mitigation != null)
{
text.Append($": {input.Mitigation.Action}");
if (!string.IsNullOrEmpty(input.Mitigation.Details))
{
text.Append($" ({input.Mitigation.Details})");
}
}
text.Append('.');
return new RationaleDecision
{
Verdict = input.Verdict,
Score = input.Score,
Recommendation = input.Recommendation,
Mitigation = input.Mitigation,
FormattedText = text.ToString()
};
}
private string ComputeRationaleId(VerdictRationale rationale)
{
var canonicalJson = CanonJson.Serialize(rationale with { RationaleId = string.Empty });
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonicalJson));
return $"rat:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -5,6 +5,6 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0448-M | DONE | Maintainability audit for StellaOps.Policy.Persistence. |
| AUDIT-0448-T | DONE | Test coverage audit for StellaOps.Policy.Persistence. |
| AUDIT-0448-A | TODO | APPLY pending approval for StellaOps.Policy.Persistence. |
| AUDIT-0448-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Persistence. |
| AUDIT-0448-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Persistence. |
| AUDIT-0448-A | TODO | Revalidated 2026-01-07 (open findings). |

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json.Nodes;
using StellaOps.Policy.Unknowns.Models;
@@ -81,7 +82,7 @@ public static class BudgetExceededEventFactory
["action"] = payload.Action,
["totalUnknowns"] = payload.TotalUnknowns,
["violationCount"] = payload.ViolationCount,
["timestamp"] = payload.Timestamp.ToString("O")
["timestamp"] = payload.Timestamp.ToString("O", CultureInfo.InvariantCulture)
};
if (payload.TotalLimit.HasValue)

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
@@ -147,7 +148,7 @@ public sealed class BudgetThresholdNotifier
["percentageUsed"] = budget.PercentageUsed,
["status"] = budget.Status.ToString().ToLowerInvariant(),
["severity"] = severity,
["timestamp"] = timeProvider.GetUtcNow().ToString("O")
["timestamp"] = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
}
}

View File

@@ -0,0 +1,229 @@
// <copyright file="FacetQuotaGate.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Facet;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Configuration options for <see cref="FacetQuotaGate"/>.
/// </summary>
public sealed record FacetQuotaGateOptions
{
/// <summary>
/// Gets or sets a value indicating whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gets or sets the action to take when no facet seal is available for comparison.
/// </summary>
public NoSealAction NoSealAction { get; init; } = NoSealAction.Pass;
/// <summary>
/// Gets or sets the default quota to apply when no facet-specific quota is configured.
/// </summary>
public FacetQuota DefaultQuota { get; init; } = FacetQuota.Default;
/// <summary>
/// Gets or sets per-facet quota overrides.
/// </summary>
public ImmutableDictionary<string, FacetQuota> FacetQuotas { get; init; } =
ImmutableDictionary<string, FacetQuota>.Empty;
}
/// <summary>
/// Specifies the action when no baseline seal is available.
/// </summary>
public enum NoSealAction
{
/// <summary>
/// Pass the gate when no seal is available (first scan).
/// </summary>
Pass,
/// <summary>
/// Warn when no seal is available.
/// </summary>
Warn,
/// <summary>
/// Block when no seal is available.
/// </summary>
Block
}
/// <summary>
/// Policy gate that enforces per-facet drift quotas.
/// This gate evaluates facet drift reports and enforces quotas configured per facet.
/// </summary>
/// <remarks>
/// The FacetQuotaGate operates on pre-computed <see cref="FacetDriftReport"/> instances,
/// which should be attached to the <see cref="PolicyGateContext"/> before evaluation.
/// If no drift report is available, the gate behavior is determined by <see cref="FacetQuotaGateOptions.NoSealAction"/>.
/// </remarks>
public sealed class FacetQuotaGate : IPolicyGate
{
private readonly FacetQuotaGateOptions _options;
private readonly IFacetDriftDetector _driftDetector;
private readonly ILogger<FacetQuotaGate> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FacetQuotaGate"/> class.
/// </summary>
/// <param name="options">Gate configuration options.</param>
/// <param name="driftDetector">The facet drift detector.</param>
/// <param name="logger">Logger instance.</param>
public FacetQuotaGate(
FacetQuotaGateOptions? options = null,
IFacetDriftDetector? driftDetector = null,
ILogger<FacetQuotaGate>? logger = null)
{
_options = options ?? new FacetQuotaGateOptions();
_driftDetector = driftDetector ?? throw new ArgumentNullException(nameof(driftDetector));
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FacetQuotaGate>.Instance;
}
/// <inheritdoc/>
public Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
// Check if gate is enabled
if (!_options.Enabled)
{
return Task.FromResult(Pass("Gate disabled"));
}
// Check for drift report in metadata
var driftReport = GetDriftReportFromContext(context);
if (driftReport is null)
{
return Task.FromResult(HandleNoSeal());
}
// Evaluate drift report against quotas
var result = EvaluateDriftReport(driftReport);
return Task.FromResult(result);
}
private static FacetDriftReport? GetDriftReportFromContext(PolicyGateContext context)
{
// Drift report is expected to be in metadata under a well-known key
if (context.Metadata?.TryGetValue("FacetDriftReport", out var value) == true &&
value is string json)
{
// In a real implementation, deserialize from JSON
// For now, return null to trigger the no-seal path
return null;
}
return null;
}
private GateResult HandleNoSeal()
{
return _options.NoSealAction switch
{
NoSealAction.Pass => Pass("No baseline seal available - first scan"),
NoSealAction.Warn => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = true,
Reason = "no_baseline_seal",
Details = ImmutableDictionary<string, object>.Empty
.Add("action", "warn")
.Add("message", "No baseline seal available for comparison")
},
NoSealAction.Block => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = false,
Reason = "no_baseline_seal",
Details = ImmutableDictionary<string, object>.Empty
.Add("action", "block")
.Add("message", "Baseline seal required but not available")
},
_ => Pass("Unknown NoSealAction - defaulting to pass")
};
}
private GateResult EvaluateDriftReport(FacetDriftReport report)
{
// Find worst verdict across all facets
var worstVerdict = report.OverallVerdict;
var breachedFacets = report.FacetDrifts
.Where(d => d.QuotaVerdict != QuotaVerdict.Ok)
.ToList();
if (breachedFacets.Count == 0)
{
_logger.LogDebug("All facets within quota limits");
return Pass("All facets within quota limits");
}
// Build details
var details = ImmutableDictionary<string, object>.Empty
.Add("overallVerdict", worstVerdict.ToString())
.Add("breachedFacets", breachedFacets.Select(f => f.FacetId).ToArray())
.Add("totalChangedFiles", report.TotalChangedFiles)
.Add("imageDigest", report.ImageDigest);
foreach (var facet in breachedFacets)
{
details = details.Add(
$"facet:{facet.FacetId}",
new Dictionary<string, object>
{
["verdict"] = facet.QuotaVerdict.ToString(),
["churnPercent"] = facet.ChurnPercent,
["added"] = facet.Added.Length,
["removed"] = facet.Removed.Length,
["modified"] = facet.Modified.Length
});
}
return worstVerdict switch
{
QuotaVerdict.Ok => Pass("All quotas satisfied"),
QuotaVerdict.Warning => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = true,
Reason = "quota_warning",
Details = details
},
QuotaVerdict.Blocked => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = false,
Reason = "quota_exceeded",
Details = details
},
QuotaVerdict.RequiresVex => new GateResult
{
GateName = nameof(FacetQuotaGate),
Passed = false,
Reason = "requires_vex_authorization",
Details = details.Add("vexRequired", true)
},
_ => Pass("Unknown verdict - defaulting to pass")
};
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(FacetQuotaGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty
};
}

View File

@@ -0,0 +1,73 @@
// <copyright file="FacetQuotaGateServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Facet;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Extension methods for registering <see cref="FacetQuotaGate"/> with dependency injection.
/// </summary>
public static class FacetQuotaGateServiceCollectionExtensions
{
/// <summary>
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with default options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetQuotaGate(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
return services.AddFacetQuotaGate(_ => { });
}
/// <summary>
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with custom configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure <see cref="FacetQuotaGateOptions"/>.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetQuotaGate(
this IServiceCollection services,
Action<FacetQuotaGateOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
var options = new FacetQuotaGateOptions();
configure(options);
// Ensure facet drift detector is registered
services.TryAddSingleton<IFacetDriftDetector>(sp =>
{
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
return new FacetDriftDetector(timeProvider);
});
// Register the gate options
services.AddSingleton(options);
// Register the gate
services.TryAddSingleton<FacetQuotaGate>();
return services;
}
/// <summary>
/// Registers the <see cref="FacetQuotaGate"/> with a <see cref="IPolicyGateRegistry"/>.
/// </summary>
/// <param name="registry">The policy gate registry.</param>
/// <param name="gateName">Optional custom gate name. Defaults to "facet-quota".</param>
/// <returns>The registry for chaining.</returns>
public static IPolicyGateRegistry RegisterFacetQuotaGate(
this IPolicyGateRegistry registry,
string gateName = "facet-quota")
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register<FacetQuotaGate>(gateName);
return registry;
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
@@ -133,7 +134,7 @@ public static class PolicyDigest
if (rule.Expires is DateTimeOffset expires)
{
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
writer.WriteString("expires", expires.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(rule.Justification))
@@ -159,7 +160,7 @@ public static class PolicyDigest
{
if (ignore.Until is DateTimeOffset until)
{
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
writer.WriteString("until", until.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(ignore.Justification))

View File

@@ -1,17 +1,43 @@
using System;
using System.Collections.Immutable;
using StellaOps.Policy.Determinization.Models;
namespace StellaOps.Policy;
// NOTE: GuardRails type has been consolidated into StellaOps.Policy.Determinization.Models.GuardRails.
// Use that type for runtime monitoring requirements in GuardedPass verdicts.
/// <summary>
/// Status outcomes for policy verdicts.
/// </summary>
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex,
/// <summary>Finding meets policy requirements.</summary>
Pass = 0,
/// <summary>
/// Finding allowed with runtime monitoring enabled.
/// Used for uncertain observations that don't exceed risk thresholds.
/// </summary>
GuardedPass = 1,
/// <summary>Finding fails policy checks; must be remediated.</summary>
Blocked = 2,
/// <summary>Finding deliberately ignored via exception.</summary>
Ignored = 3,
/// <summary>Finding passes but with warnings.</summary>
Warned = 4,
/// <summary>Decision deferred; needs additional evidence.</summary>
Deferred = 5,
/// <summary>Decision escalated for human review.</summary>
Escalated = 6,
/// <summary>VEX statement required to make decision.</summary>
RequiresVex = 7
}
public sealed record PolicyVerdict(
@@ -29,8 +55,20 @@ public sealed record PolicyVerdict(
string? ConfidenceBand = null,
double? UnknownAgeDays = null,
string? SourceTrust = null,
string? Reachability = null)
string? Reachability = null,
GuardRails? GuardRails = null,
UncertaintyScore? UncertaintyScore = null,
ObservationState? SuggestedObservationState = null)
{
/// <summary>
/// Whether this verdict allows the finding to proceed (Pass or GuardedPass).
/// </summary>
public bool IsAllowing => Status is PolicyVerdictStatus.Pass or PolicyVerdictStatus.GuardedPass;
/// <summary>
/// Whether this verdict requires monitoring (GuardedPass only).
/// </summary>
public bool RequiresMonitoring => Status == PolicyVerdictStatus.GuardedPass;
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
{
var inputs = ImmutableDictionary<string, double>.Empty;
@@ -49,7 +87,10 @@ public sealed record PolicyVerdict(
ConfidenceBand: null,
UnknownAgeDays: null,
SourceTrust: null,
Reachability: null);
Reachability: null,
GuardRails: null,
UncertaintyScore: null,
SuggestedObservationState: null);
}
public ImmutableDictionary<string, double> GetInputs()

View File

@@ -5,6 +5,7 @@
// Description: Deterministic hashing for proof nodes and root hash computation
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -110,7 +111,7 @@ public static class ProofHashing
["ruleId"] = node.RuleId,
["seed"] = Convert.ToBase64String(node.Seed),
["total"] = node.Total,
["tsUtc"] = node.TsUtc.ToUniversalTime().ToString("O")
["tsUtc"] = node.TsUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)
};
return SerializeCanonical(obj);

Some files were not shown because too many files have changed in this diff Show More