Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -0,0 +1,35 @@
# StellaOps.Policy.Exceptions - Agent Charter
## Mission
- Deliver deterministic Exception Objects, recheck policies, and evidence hook validation that integrate with Policy Engine evaluation and audit trails.
- Keep exception persistence and evaluation reproducible and offline friendly.
## Roles
- Backend / Policy engineer (.NET 10, C# preview).
- QA engineer (unit and integration tests).
## Required Reading (treat as read before DOING)
- `docs/modules/policy/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/product-advisories/archived/20-Dec-2025 - Moat Explanation - Exception management as auditable objects.md`
- `docs/product-advisories/22-Dec-2026 - UI Patterns for Triage and Replay.md`
- Current sprint file in `docs/implplan/SPRINT_3900_*.md`
## Working Directory & Boundaries
- Primary scope: `src/Policy/__Libraries/StellaOps.Policy.Exceptions/**`.
- Related migrations: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/Migrations`.
- Tests: `src/Policy/__Tests/StellaOps.Policy.Exceptions.Tests/**` and `src/Policy/__Tests/StellaOps.Policy.Storage.Postgres.Tests/**`.
- Avoid cross-module edits unless the sprint explicitly allows.
## Determinism & Offline Rules
- Use UTC timestamps and stable ordering; avoid random or wall-clock based identifiers.
- No external network calls; rely on injected services and local data sources only.
## Testing Expectations
- Add or update unit tests for models and services.
- Add or update integration tests for repository and migration changes.
- Ensure serialization and ordering are deterministic.
## Workflow
- Update task status to `DOING`/`DONE` in the sprint file and `src/Policy/__Libraries/StellaOps.Policy/TASKS.md`.
- Record design decisions in sprint `Decisions & Risks` and update docs when contracts change.

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy.Exceptions.Models;
/// <summary>
/// Evidence hook requiring specific attestations before exception approval.
/// </summary>
public sealed record EvidenceHook
{
/// <summary>
/// Unique identifier for this hook.
/// </summary>
public required string HookId { get; init; }
/// <summary>
/// Type of evidence required.
/// </summary>
public required EvidenceType Type { get; init; }
/// <summary>
/// Human-readable description of the requirement.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Whether this evidence is mandatory for approval.
/// </summary>
public bool IsMandatory { get; init; } = true;
/// <summary>
/// Schema or predicate type for validation.
/// </summary>
public string? ValidationSchema { get; init; }
/// <summary>
/// Maximum age of evidence (for freshness validation).
/// </summary>
public TimeSpan? MaxAge { get; init; }
/// <summary>
/// Required trust score for evidence source.
/// </summary>
public decimal? MinTrustScore { get; init; }
}
/// <summary>
/// Types of evidence that can be required.
/// </summary>
public enum EvidenceType
{
/// <summary>Feature flag is disabled in target environment.</summary>
FeatureFlagDisabled,
/// <summary>Backport PR has been merged.</summary>
BackportMerged,
/// <summary>Compensating control attestation.</summary>
CompensatingControl,
/// <summary>Security review completed.</summary>
SecurityReview,
/// <summary>Runtime mitigation in place.</summary>
RuntimeMitigation,
/// <summary>WAF rule deployed.</summary>
WAFRuleDeployed,
/// <summary>Custom attestation type.</summary>
CustomAttestation
}
/// <summary>
/// Evidence submitted to satisfy a hook.
/// </summary>
public sealed record SubmittedEvidence
{
/// <summary>
/// Unique identifier for this evidence submission.
/// </summary>
public required string EvidenceId { get; init; }
/// <summary>
/// Hook this evidence satisfies.
/// </summary>
public required string HookId { get; init; }
/// <summary>
/// Type of evidence.
/// </summary>
public required EvidenceType Type { get; init; }
/// <summary>
/// Reference to the evidence (URL, attestation ID, etc.).
/// </summary>
public required string Reference { get; init; }
/// <summary>
/// Evidence content or payload.
/// </summary>
public string? Content { get; init; }
/// <summary>
/// DSSE envelope if signed.
/// </summary>
public string? DsseEnvelope { get; init; }
/// <summary>
/// Whether signature was verified.
/// </summary>
public bool SignatureVerified { get; init; }
/// <summary>
/// Trust score of evidence source.
/// </summary>
public decimal TrustScore { get; init; }
/// <summary>
/// When evidence was submitted.
/// </summary>
public required DateTimeOffset SubmittedAt { get; init; }
/// <summary>
/// Who submitted the evidence.
/// </summary>
public required string SubmittedBy { get; init; }
/// <summary>
/// Validation status.
/// </summary>
public required EvidenceValidationStatus ValidationStatus { get; init; }
/// <summary>
/// Validation error if any.
/// </summary>
public string? ValidationError { get; init; }
}
/// <summary>
/// Status of evidence validation.
/// </summary>
public enum EvidenceValidationStatus
{
Pending,
Valid,
Invalid,
Expired,
InsufficientTrust
}
/// <summary>
/// Registry of required evidence hooks for an exception type.
/// </summary>
public sealed record EvidenceRequirements
{
/// <summary>
/// Required evidence hooks.
/// </summary>
public required ImmutableArray<EvidenceHook> Hooks { get; init; }
/// <summary>
/// Evidence submitted so far.
/// </summary>
public ImmutableArray<SubmittedEvidence> SubmittedEvidence { get; init; } = [];
/// <summary>
/// Whether all mandatory evidence is satisfied.
/// </summary>
public bool IsSatisfied => Hooks
.Where(h => h.IsMandatory)
.All(h => SubmittedEvidence.Any(e =>
e.HookId == h.HookId &&
e.ValidationStatus == EvidenceValidationStatus.Valid));
/// <summary>
/// Missing mandatory evidence.
/// </summary>
public ImmutableArray<EvidenceHook> MissingEvidence => Hooks
.Where(h => h.IsMandatory)
.Where(h => !SubmittedEvidence.Any(e =>
e.HookId == h.HookId &&
e.ValidationStatus == EvidenceValidationStatus.Valid))
.ToImmutableArray();
}

View File

@@ -238,6 +238,11 @@ public sealed record ExceptionObject
/// </summary>
public ImmutableArray<string> EvidenceRefs { get; init; } = [];
/// <summary>
/// Evidence requirements and submissions tied to this exception.
/// </summary>
public EvidenceRequirements? EvidenceRequirements { get; init; }
/// <summary>
/// Compensating controls in place that mitigate the risk.
/// </summary>
@@ -254,6 +259,41 @@ public sealed record ExceptionObject
/// </summary>
public string? TicketRef { get; init; }
/// <summary>
/// Reference to the applied recheck policy configuration.
/// </summary>
public string? RecheckPolicyId { get; init; }
/// <summary>
/// Recheck policy that governs automatic re-evaluation.
/// If null, exception is only invalidated by expiry.
/// </summary>
public RecheckPolicy? RecheckPolicy { get; init; }
/// <summary>
/// Result of last recheck evaluation.
/// </summary>
public RecheckEvaluationResult? LastRecheckResult { get; init; }
/// <summary>
/// When recheck was last evaluated.
/// </summary>
public DateTimeOffset? LastRecheckAt { get; init; }
/// <summary>
/// Whether this exception is blocked by recheck policy.
/// </summary>
public bool IsBlockedByRecheck =>
LastRecheckResult?.IsTriggered == true &&
LastRecheckResult.RecommendedAction == RecheckAction.Block;
/// <summary>
/// Whether this exception requires re-approval.
/// </summary>
public bool RequiresReapproval =>
LastRecheckResult?.IsTriggered == true &&
LastRecheckResult.RecommendedAction == RecheckAction.RequireReapproval;
/// <summary>
/// Determines if this exception is currently effective.
/// </summary>

View File

@@ -0,0 +1,157 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy.Exceptions.Models;
/// <summary>
/// Policy defining conditions that trigger exception re-evaluation.
/// When any condition is met, the exception may be invalidated or flagged.
/// </summary>
public sealed record RecheckPolicy
{
/// <summary>
/// Unique identifier for this policy configuration.
/// </summary>
public required string PolicyId { get; init; }
/// <summary>
/// Human-readable name for this policy.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Conditions that trigger recheck.
/// </summary>
public required ImmutableArray<RecheckCondition> Conditions { get; init; }
/// <summary>
/// Default action when any condition is triggered.
/// </summary>
public required RecheckAction DefaultAction { get; init; }
/// <summary>
/// Whether this policy is active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// When this policy was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// A single condition that triggers exception re-evaluation.
/// </summary>
public sealed record RecheckCondition
{
/// <summary>
/// Type of condition to check.
/// </summary>
public required RecheckConditionType Type { get; init; }
/// <summary>
/// Threshold value (interpretation depends on Type).
/// </summary>
public decimal? Threshold { get; init; }
/// <summary>
/// Environment scopes where this condition applies.
/// </summary>
public ImmutableArray<string> EnvironmentScope { get; init; } = [];
/// <summary>
/// Action to take when this specific condition is triggered.
/// If null, uses policy's DefaultAction.
/// </summary>
public RecheckAction? Action { get; init; }
/// <summary>
/// Human-readable description of this condition.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Types of recheck conditions.
/// </summary>
public enum RecheckConditionType
{
/// <summary>Reachability graph changes (new paths discovered).</summary>
ReachGraphChange,
/// <summary>EPSS score exceeds threshold.</summary>
EPSSAbove,
/// <summary>CVSS score exceeds threshold.</summary>
CVSSAbove,
/// <summary>Unknown budget exceeds threshold.</summary>
UnknownsAbove,
/// <summary>New CVE added to same package.</summary>
NewCVEInPackage,
/// <summary>KEV (Known Exploited Vulnerability) flag set.</summary>
KEVFlagged,
/// <summary>Exception nearing expiry (days before).</summary>
ExpiryWithin,
/// <summary>VEX status changes (e.g., from NotAffected to Affected).</summary>
VEXStatusChange,
/// <summary>Package version changes.</summary>
PackageVersionChange
}
/// <summary>
/// Action to take when a recheck condition is triggered.
/// </summary>
public enum RecheckAction
{
/// <summary>Log warning but allow exception to remain active.</summary>
Warn,
/// <summary>Require manual re-approval of exception.</summary>
RequireReapproval,
/// <summary>Automatically revoke the exception.</summary>
Revoke,
/// <summary>Block build/deployment pipeline.</summary>
Block
}
/// <summary>
/// Result of evaluating recheck conditions against an exception.
/// </summary>
public sealed record RecheckEvaluationResult
{
/// <summary>Whether any conditions were triggered.</summary>
public required bool IsTriggered { get; init; }
/// <summary>List of triggered conditions with details.</summary>
public required ImmutableArray<TriggeredCondition> TriggeredConditions { get; init; }
/// <summary>Recommended action based on triggered conditions.</summary>
public required RecheckAction? RecommendedAction { get; init; }
/// <summary>When this evaluation was performed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Human-readable summary.</summary>
public string Summary => IsTriggered
? $"{TriggeredConditions.Length} condition(s) triggered: {string.Join(", ", TriggeredConditions.Select(t => t.Type))}"
: "No conditions triggered";
}
/// <summary>
/// Details of a triggered recheck condition.
/// </summary>
public sealed record TriggeredCondition(
RecheckConditionType Type,
string Description,
decimal? CurrentValue,
decimal? ThresholdValue,
RecheckAction Action);

View File

@@ -59,7 +59,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
environments, tenant_id, owner_id, requester_id, approver_ids,
created_at, updated_at, approved_at, expires_at,
reason_code, rationale, evidence_refs, compensating_controls,
metadata, ticket_ref
metadata, ticket_ref, recheck_policy_id, last_recheck_result, last_recheck_at
)
VALUES (
@id, @exception_id, @version, @status, @type,
@@ -67,7 +67,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
@environments, @tenant_id, @owner_id, @requester_id, @approver_ids,
@created_at, @updated_at, @approved_at, @expires_at,
@reason_code, @rationale, @evidence_refs::jsonb, @compensating_controls::jsonb,
@metadata::jsonb, @ticket_ref
@metadata::jsonb, @ticket_ref, @recheck_policy_id, @last_recheck_result::jsonb, @last_recheck_at
)
RETURNING *
""";
@@ -160,7 +160,10 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
evidence_refs = @evidence_refs::jsonb,
compensating_controls = @compensating_controls::jsonb,
metadata = @metadata::jsonb,
ticket_ref = @ticket_ref
ticket_ref = @ticket_ref,
recheck_policy_id = @recheck_policy_id,
last_recheck_result = @last_recheck_result::jsonb,
last_recheck_at = @last_recheck_at
WHERE exception_id = @exception_id AND version = @old_version
RETURNING *
""";
@@ -658,6 +661,13 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
cmd.Parameters.AddWithValue("compensating_controls", JsonSerializer.Serialize(ex.CompensatingControls, JsonOptions));
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(ex.Metadata, JsonOptions));
cmd.Parameters.AddWithValue("ticket_ref", (object?)ex.TicketRef ?? DBNull.Value);
cmd.Parameters.AddWithValue("recheck_policy_id", (object?)(ex.RecheckPolicyId ?? ex.RecheckPolicy?.PolicyId) ?? DBNull.Value);
cmd.Parameters.AddWithValue(
"last_recheck_result",
ex.LastRecheckResult is not null
? JsonSerializer.Serialize(ex.LastRecheckResult, JsonOptions)
: DBNull.Value);
cmd.Parameters.AddWithValue("last_recheck_at", (object?)ex.LastRecheckAt ?? DBNull.Value);
}
private static ExceptionObject MapException(NpgsqlDataReader reader)
@@ -675,6 +685,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
var evidenceRefs = ParseJsonArray<string>(GetNullableString(reader, "evidence_refs") ?? "[]");
var compensatingControls = ParseJsonArray<string>(GetNullableString(reader, "compensating_controls") ?? "[]");
var metadata = ParseJsonDict(GetNullableString(reader, "metadata") ?? "{}");
var lastRecheckResult = ParseJsonObject<RecheckEvaluationResult>(GetNullableString(reader, "last_recheck_result"));
return new ExceptionObject
{
@@ -695,7 +706,10 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
EvidenceRefs = evidenceRefs.ToImmutableArray(),
CompensatingControls = compensatingControls.ToImmutableArray(),
Metadata = metadata.ToImmutableDictionary(),
TicketRef = GetNullableString(reader, "ticket_ref")
TicketRef = GetNullableString(reader, "ticket_ref"),
RecheckPolicyId = GetNullableString(reader, "recheck_policy_id"),
LastRecheckResult = lastRecheckResult,
LastRecheckAt = GetNullableDateTimeOffset(reader, "last_recheck_at")
};
}
@@ -763,6 +777,23 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
}
}
private static T? ParseJsonObject<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch
{
return default;
}
}
private static Dictionary<string, string> ParseJsonDict(string json)
{
try

View File

@@ -0,0 +1,214 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Services;
/// <summary>
/// Validates that all required evidence is present before exception approval.
/// </summary>
public sealed class EvidenceRequirementValidator : IEvidenceRequirementValidator
{
private readonly IEvidenceHookRegistry _hookRegistry;
private readonly IAttestationVerifier _attestationVerifier;
private readonly ITrustScoreService _trustScoreService;
private readonly IEvidenceSchemaValidator _schemaValidator;
private readonly ILogger<EvidenceRequirementValidator> _logger;
public EvidenceRequirementValidator(
IEvidenceHookRegistry hookRegistry,
IAttestationVerifier attestationVerifier,
ITrustScoreService trustScoreService,
IEvidenceSchemaValidator schemaValidator,
ILogger<EvidenceRequirementValidator> logger)
{
_hookRegistry = hookRegistry ?? throw new ArgumentNullException(nameof(hookRegistry));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_trustScoreService = trustScoreService ?? throw new ArgumentNullException(nameof(trustScoreService));
_schemaValidator = schemaValidator ?? throw new ArgumentNullException(nameof(schemaValidator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates that an exception can be approved based on evidence requirements.
/// </summary>
public async Task<EvidenceValidationResult> ValidateForApprovalAsync(
ExceptionObject exception,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(exception);
_logger.LogInformation(
"Validating evidence requirements for exception {ExceptionId}",
exception.ExceptionId);
var requiredHooks = await _hookRegistry
.GetRequiredHooksAsync(exception.Type, exception.Scope, ct)
.ConfigureAwait(false);
if (requiredHooks.Length == 0)
{
return new EvidenceValidationResult
{
IsValid = true,
MissingEvidence = [],
InvalidEvidence = [],
ValidEvidence = [],
Message = "No evidence requirements for this exception type"
};
}
var missingEvidence = new List<EvidenceHook>();
var invalidEvidence = new List<(EvidenceHook Hook, SubmittedEvidence Evidence, string Error)>();
var validEvidence = new List<SubmittedEvidence>();
var submitted = exception.EvidenceRequirements?.SubmittedEvidence ?? [];
foreach (var hook in requiredHooks.Where(h => h.IsMandatory))
{
var evidence = submitted.FirstOrDefault(e => e.HookId == hook.HookId);
if (evidence is null)
{
missingEvidence.Add(hook);
continue;
}
var validation = await ValidateEvidenceAsync(hook, evidence, ct).ConfigureAwait(false);
if (!validation.IsValid)
{
invalidEvidence.Add((hook, evidence, validation.Error ?? "Evidence validation failed"));
}
else
{
validEvidence.Add(evidence);
}
}
var isValid = missingEvidence.Count == 0 && invalidEvidence.Count == 0;
return new EvidenceValidationResult
{
IsValid = isValid,
MissingEvidence = missingEvidence.ToImmutableArray(),
InvalidEvidence = invalidEvidence.Select(e => new InvalidEvidenceEntry(
e.Hook.HookId, e.Evidence.EvidenceId, e.Error)).ToImmutableArray(),
ValidEvidence = validEvidence.ToImmutableArray(),
Message = isValid
? "All evidence requirements satisfied"
: BuildValidationMessage(missingEvidence, invalidEvidence)
};
}
private async Task<(bool IsValid, string? Error)> ValidateEvidenceAsync(
EvidenceHook hook,
SubmittedEvidence evidence,
CancellationToken ct)
{
if (hook.MaxAge.HasValue)
{
var age = DateTimeOffset.UtcNow - evidence.SubmittedAt;
if (age > hook.MaxAge.Value)
{
return (false, $"Evidence is stale (age: {age.TotalHours:F0}h, max: {hook.MaxAge.Value.TotalHours:F0}h)");
}
}
if (hook.MinTrustScore.HasValue)
{
var trustScore = await _trustScoreService.GetScoreAsync(evidence.Reference, ct).ConfigureAwait(false);
if (trustScore < hook.MinTrustScore.Value)
{
return (false, $"Evidence trust score {trustScore:P0} below minimum {hook.MinTrustScore:P0}");
}
}
if (!string.IsNullOrWhiteSpace(hook.ValidationSchema))
{
var schemaResult = await _schemaValidator
.ValidateAsync(hook.ValidationSchema, evidence.Content, ct)
.ConfigureAwait(false);
if (!schemaResult.IsValid)
{
return (false, schemaResult.Error ?? "Schema validation failed");
}
}
if (evidence.DsseEnvelope is not null)
{
var verification = await _attestationVerifier.VerifyAsync(evidence.DsseEnvelope, ct).ConfigureAwait(false);
if (!verification.IsValid)
{
return (false, $"Signature verification failed: {verification.Error}");
}
}
return (true, null);
}
private static string BuildValidationMessage(
IReadOnlyCollection<EvidenceHook> missing,
IReadOnlyCollection<(EvidenceHook Hook, SubmittedEvidence Evidence, string Error)> invalid)
{
var parts = new List<string>();
if (missing.Count > 0)
{
parts.Add($"Missing evidence: {string.Join(", ", missing.Select(h => h.Type))}");
}
if (invalid.Count > 0)
{
parts.Add($"Invalid evidence: {string.Join(", ", invalid.Select(e => $"{e.Hook.Type}: {e.Error}"))}");
}
return string.Join("; ", parts);
}
}
public interface IEvidenceRequirementValidator
{
Task<EvidenceValidationResult> ValidateForApprovalAsync(
ExceptionObject exception,
CancellationToken ct = default);
}
public interface IEvidenceHookRegistry
{
Task<ImmutableArray<EvidenceHook>> GetRequiredHooksAsync(
ExceptionType exceptionType,
ExceptionScope scope,
CancellationToken ct = default);
}
public interface IAttestationVerifier
{
Task<EvidenceVerificationResult> VerifyAsync(string dsseEnvelope, CancellationToken ct = default);
}
public sealed record EvidenceVerificationResult(bool IsValid, string? Error);
public interface ITrustScoreService
{
Task<decimal> GetScoreAsync(string reference, CancellationToken ct = default);
}
public interface IEvidenceSchemaValidator
{
Task<EvidenceSchemaValidationResult> ValidateAsync(
string schemaId,
string? content,
CancellationToken ct = default);
}
public sealed record EvidenceSchemaValidationResult(bool IsValid, string? Error);
public sealed record EvidenceValidationResult
{
public required bool IsValid { get; init; }
public required ImmutableArray<EvidenceHook> MissingEvidence { get; init; }
public required ImmutableArray<InvalidEvidenceEntry> InvalidEvidence { get; init; }
public ImmutableArray<SubmittedEvidence> ValidEvidence { get; init; } = [];
public required string Message { get; init; }
}
public sealed record InvalidEvidenceEntry(string HookId, string EvidenceId, string Error);

View File

@@ -0,0 +1,244 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Services;
/// <summary>
/// Context for evaluating recheck conditions against current state.
/// </summary>
public sealed record RecheckEvaluationContext
{
/// <summary>Artifact digest under evaluation.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>When this evaluation was performed.</summary>
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Environment name (prod, staging, dev).</summary>
public string? Environment { get; init; }
/// <summary>Reachability graph changed since approval.</summary>
public bool ReachGraphChanged { get; init; }
/// <summary>Current EPSS score for the finding.</summary>
public decimal? EpssScore { get; init; }
/// <summary>Current CVSS score for the finding.</summary>
public decimal? CvssScore { get; init; }
/// <summary>Current unknowns count for the artifact.</summary>
public int? UnknownsCount { get; init; }
/// <summary>New CVE discovered in the same package.</summary>
public bool NewCveInPackage { get; init; }
/// <summary>KEV flag set for the vulnerability.</summary>
public bool KevFlagged { get; init; }
/// <summary>VEX status changed since approval.</summary>
public bool VexStatusChanged { get; init; }
/// <summary>Package version changed since approval.</summary>
public bool PackageVersionChanged { get; init; }
}
/// <summary>
/// Evaluates recheck conditions against current vulnerability state.
/// </summary>
public interface IRecheckEvaluationService
{
/// <summary>
/// Evaluates recheck conditions for an exception.
/// </summary>
/// <param name="exception">Exception to evaluate.</param>
/// <param name="context">Current evaluation context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Recheck evaluation result.</returns>
Task<RecheckEvaluationResult> EvaluateAsync(
ExceptionObject exception,
RecheckEvaluationContext context,
CancellationToken ct = default);
}
/// <summary>
/// Default implementation of <see cref="IRecheckEvaluationService"/>.
/// </summary>
public sealed class RecheckEvaluationService : IRecheckEvaluationService
{
private static readonly ImmutableDictionary<RecheckAction, int> ActionPriority =
new Dictionary<RecheckAction, int>
{
[RecheckAction.Warn] = 1,
[RecheckAction.RequireReapproval] = 2,
[RecheckAction.Revoke] = 3,
[RecheckAction.Block] = 4
}.ToImmutableDictionary();
/// <inheritdoc />
public Task<RecheckEvaluationResult> EvaluateAsync(
ExceptionObject exception,
RecheckEvaluationContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(exception);
ArgumentNullException.ThrowIfNull(context);
var policy = exception.RecheckPolicy;
if (policy is null || !policy.IsActive)
{
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = false,
TriggeredConditions = [],
RecommendedAction = null,
EvaluatedAt = context.EvaluatedAt
});
}
var triggered = new List<TriggeredCondition>();
foreach (var condition in policy.Conditions)
{
if (!AppliesToEnvironment(condition, context.Environment))
{
continue;
}
if (IsTriggered(condition, exception, context, out var triggeredCondition))
{
triggered.Add(triggeredCondition);
}
}
if (triggered.Count == 0)
{
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = false,
TriggeredConditions = [],
RecommendedAction = null,
EvaluatedAt = context.EvaluatedAt
});
}
var recommended = triggered
.Select(t => t.Action)
.OrderByDescending(GetActionPriority)
.FirstOrDefault();
return Task.FromResult(new RecheckEvaluationResult
{
IsTriggered = true,
TriggeredConditions = triggered.ToImmutableArray(),
RecommendedAction = recommended,
EvaluatedAt = context.EvaluatedAt
});
}
private static bool AppliesToEnvironment(RecheckCondition condition, string? environment)
{
if (condition.EnvironmentScope.Length == 0)
{
return true;
}
if (string.IsNullOrWhiteSpace(environment))
{
return false;
}
return condition.EnvironmentScope.Contains(environment, StringComparer.OrdinalIgnoreCase);
}
private static bool IsTriggered(
RecheckCondition condition,
ExceptionObject exception,
RecheckEvaluationContext context,
out TriggeredCondition triggered)
{
triggered = default!;
var action = condition.Action ?? exception.RecheckPolicy?.DefaultAction ?? RecheckAction.Warn;
var description = condition.Description ?? $"{condition.Type} triggered";
switch (condition.Type)
{
case RecheckConditionType.ReachGraphChange:
if (context.ReachGraphChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.EPSSAbove:
if (condition.Threshold.HasValue && context.EpssScore.HasValue &&
context.EpssScore.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.EpssScore, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.CVSSAbove:
if (condition.Threshold.HasValue && context.CvssScore.HasValue &&
context.CvssScore.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.CvssScore, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.UnknownsAbove:
if (condition.Threshold.HasValue && context.UnknownsCount.HasValue &&
context.UnknownsCount.Value >= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, context.UnknownsCount.Value, condition.Threshold, action);
return true;
}
return false;
case RecheckConditionType.NewCVEInPackage:
if (context.NewCveInPackage)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.KEVFlagged:
if (context.KevFlagged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.ExpiryWithin:
if (condition.Threshold.HasValue)
{
var daysUntilExpiry = (decimal)(exception.ExpiresAt - context.EvaluatedAt).TotalDays;
if (daysUntilExpiry <= condition.Threshold.Value)
{
triggered = new TriggeredCondition(condition.Type, description, daysUntilExpiry, condition.Threshold, action);
return true;
}
}
return false;
case RecheckConditionType.VEXStatusChange:
if (context.VexStatusChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
case RecheckConditionType.PackageVersionChange:
if (context.PackageVersionChanged)
{
triggered = new TriggeredCondition(condition.Type, description, 1, null, action);
return true;
}
return false;
default:
return false;
}
}
private static int GetActionPriority(RecheckAction action)
{
return ActionPriority.TryGetValue(action, out var priority) ? priority : 0;
}
}

View File

@@ -0,0 +1,34 @@
# StellaOps.Policy.Storage.Postgres - Agent Charter
## Mission
- Provide deterministic PostgreSQL persistence for Policy module data (packs, risk profiles, exceptions, unknowns).
- Keep migrations idempotent, RLS-safe, and replayable in air-gapped environments.
## Roles
- Backend / database engineer (.NET 10, C# preview, PostgreSQL).
- QA engineer (integration tests with Postgres fixtures).
## Required Reading (treat as read before DOING)
- `docs/modules/policy/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- Current sprint file in `docs/implplan/SPRINT_*.md`
## Working Directory & Boundaries
- Primary scope: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/**`.
- Migrations: `src/Policy/__Libraries/StellaOps.Policy.Storage.Postgres/migrations/**`.
- Tests: `src/Policy/__Tests/StellaOps.Policy.Storage.Postgres.Tests/**`.
- Avoid cross-module edits unless the sprint explicitly allows.
## Determinism & Offline Rules
- Use UTC timestamps and stable ordering.
- Keep migrations deterministic (no volatile defaults or nondeterministic functions).
- No external network calls in repositories or tests.
## Testing Expectations
- Add/adjust integration tests for repository and migration changes.
- Use `PolicyPostgresFixture` and truncate tables between tests.
- Validate JSON serialization order and default values where applicable.
## Workflow
- Update task status to `DOING`/`DONE` in the sprint file.
- Record schema or contract changes in sprint `Decisions & Risks` and update docs when needed.

View File

@@ -0,0 +1,138 @@
-- Policy Schema Migration 010: Exception Recheck Policies and Evidence Hooks
-- Sprint: SPRINT_3900_0003_0002 - Recheck Policy and Evidence Hooks
-- Category: A (safe, can run at startup)
BEGIN;
-- =====================================================================
-- Step 1: Recheck policy registry
-- =====================================================================
CREATE TABLE IF NOT EXISTS policy.recheck_policies (
policy_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
conditions JSONB NOT NULL,
default_action TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_recheck_policies_tenant
ON policy.recheck_policies (tenant_id, is_active);
-- =====================================================================
-- Step 2: Evidence hook registry
-- =====================================================================
CREATE TABLE IF NOT EXISTS policy.evidence_hooks (
hook_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
is_mandatory BOOLEAN NOT NULL DEFAULT TRUE,
validation_schema TEXT,
max_age_seconds BIGINT,
min_trust_score DECIMAL(5,4),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_evidence_hooks_tenant_type
ON policy.evidence_hooks (tenant_id, type);
-- =====================================================================
-- Step 3: Submitted evidence
-- =====================================================================
CREATE TABLE IF NOT EXISTS policy.submitted_evidence (
evidence_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
exception_id TEXT NOT NULL REFERENCES policy.exceptions(exception_id),
hook_id TEXT NOT NULL REFERENCES policy.evidence_hooks(hook_id),
type TEXT NOT NULL,
reference TEXT NOT NULL,
content TEXT,
dsse_envelope TEXT,
signature_verified BOOLEAN NOT NULL DEFAULT FALSE,
trust_score DECIMAL(5,4) NOT NULL DEFAULT 0,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_by TEXT NOT NULL,
validation_status TEXT NOT NULL DEFAULT 'Pending',
validation_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_submitted_evidence_exception
ON policy.submitted_evidence (tenant_id, exception_id);
CREATE INDEX IF NOT EXISTS idx_submitted_evidence_hook
ON policy.submitted_evidence (tenant_id, hook_id);
CREATE INDEX IF NOT EXISTS idx_submitted_evidence_status
ON policy.submitted_evidence (tenant_id, validation_status);
-- =====================================================================
-- Step 4: Extend exceptions table with recheck tracking columns
-- =====================================================================
ALTER TABLE policy.exceptions
ADD COLUMN IF NOT EXISTS recheck_policy_id TEXT REFERENCES policy.recheck_policies(policy_id);
ALTER TABLE policy.exceptions
ADD COLUMN IF NOT EXISTS last_recheck_result JSONB;
ALTER TABLE policy.exceptions
ADD COLUMN IF NOT EXISTS last_recheck_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_exceptions_recheck_policy
ON policy.exceptions (tenant_id, recheck_policy_id)
WHERE recheck_policy_id IS NOT NULL;
-- =====================================================================
-- Step 5: Enable RLS for new tables
-- =====================================================================
ALTER TABLE policy.recheck_policies ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.evidence_hooks ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.submitted_evidence ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'recheck_policies'
AND policyname = 'recheck_policies_tenant_isolation'
) THEN
CREATE POLICY recheck_policies_tenant_isolation ON policy.recheck_policies
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'evidence_hooks'
AND policyname = 'evidence_hooks_tenant_isolation'
) THEN
CREATE POLICY evidence_hooks_tenant_isolation ON policy.evidence_hooks
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'submitted_evidence'
AND policyname = 'submitted_evidence_tenant_isolation'
) THEN
CREATE POLICY submitted_evidence_tenant_isolation ON policy.submitted_evidence
USING (tenant_id = policy_app.require_current_tenant())
WITH CHECK (tenant_id = policy_app.require_current_tenant());
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,29 @@
-- Policy Schema Migration 010: Unknowns Blast Radius + Containment Signals
-- Adds containment-related columns to policy.unknowns
-- Sprint: SPRINT_4000_0001_0002 - Unknowns BlastRadius and Containment Signals
-- Category: A (safe, can run at startup)
BEGIN;
ALTER TABLE policy.unknowns
ADD COLUMN IF NOT EXISTS blast_radius_dependents INT,
ADD COLUMN IF NOT EXISTS blast_radius_net_facing BOOLEAN,
ADD COLUMN IF NOT EXISTS blast_radius_privilege TEXT,
ADD COLUMN IF NOT EXISTS containment_seccomp TEXT,
ADD COLUMN IF NOT EXISTS containment_fs_mode TEXT,
ADD COLUMN IF NOT EXISTS containment_network_policy TEXT;
COMMENT ON COLUMN policy.unknowns.blast_radius_dependents IS
'Number of packages depending on this package';
COMMENT ON COLUMN policy.unknowns.blast_radius_net_facing IS
'Whether reachable from network entrypoints';
COMMENT ON COLUMN policy.unknowns.blast_radius_privilege IS
'Privilege level: root, user, none';
COMMENT ON COLUMN policy.unknowns.containment_seccomp IS
'Seccomp status: enforced, permissive, disabled';
COMMENT ON COLUMN policy.unknowns.containment_fs_mode IS
'Filesystem mode: ro, rw';
COMMENT ON COLUMN policy.unknowns.containment_network_policy IS
'Network policy: isolated, restricted, open';
COMMIT;

View File

@@ -0,0 +1,27 @@
-- Policy Schema Migration 011: Unknowns Reason Codes + Remediation
-- Adds reason code, remediation hint, evidence refs, and assumptions to policy.unknowns
-- Sprint: SPRINT_4100_0001_0001 - Reason-Coded Unknowns
-- Category: A (safe, can run at startup)
BEGIN;
ALTER TABLE policy.unknowns
ADD COLUMN IF NOT EXISTS reason_code TEXT,
ADD COLUMN IF NOT EXISTS remediation_hint TEXT,
ADD COLUMN IF NOT EXISTS evidence_refs JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS assumptions JSONB DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_unknowns_reason_code
ON policy.unknowns(reason_code)
WHERE reason_code IS NOT NULL;
COMMENT ON COLUMN policy.unknowns.reason_code IS
'Canonical reason code: Reachability, Identity, Provenance, VexConflict, FeedGap, ConfigUnknown, AnalyzerLimit';
COMMENT ON COLUMN policy.unknowns.remediation_hint IS
'Actionable guidance for resolving this unknown';
COMMENT ON COLUMN policy.unknowns.evidence_refs IS
'JSON array of evidence references supporting classification';
COMMENT ON COLUMN policy.unknowns.assumptions IS
'JSON array of assumptions made during analysis';
COMMIT;

View File

@@ -60,7 +60,8 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
owner_id, requester_id, approver_ids,
created_at, updated_at, approved_at, expires_at,
reason_code, rationale, evidence_refs, compensating_controls,
metadata, ticket_ref
metadata, ticket_ref,
recheck_policy_id, last_recheck_result, last_recheck_at
)
VALUES (
@exception_id, @version, @status, @type,
@@ -69,7 +70,8 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
@owner_id, @requester_id, @approver_ids,
@created_at, @updated_at, @approved_at, @expires_at,
@reason_code, @rationale, @evidence_refs::jsonb, @compensating_controls::jsonb,
@metadata::jsonb, @ticket_ref
@metadata::jsonb, @ticket_ref,
@recheck_policy_id, @last_recheck_result::jsonb, @last_recheck_at
)
RETURNING id
""";
@@ -155,7 +157,10 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
evidence_refs = @evidence_refs::jsonb,
compensating_controls = @compensating_controls::jsonb,
metadata = @metadata::jsonb,
ticket_ref = @ticket_ref
ticket_ref = @ticket_ref,
recheck_policy_id = @recheck_policy_id,
last_recheck_result = @last_recheck_result::jsonb,
last_recheck_at = @last_recheck_at
WHERE exception_id = @exception_id AND version = @current_version
""";
@@ -171,6 +176,9 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
AddJsonbParameter(updateCommand, "compensating_controls", JsonSerializer.Serialize(exception.CompensatingControls, JsonOptions));
AddJsonbParameter(updateCommand, "metadata", JsonSerializer.Serialize(exception.Metadata, JsonOptions));
AddParameter(updateCommand, "ticket_ref", (object?)exception.TicketRef ?? DBNull.Value);
AddParameter(updateCommand, "recheck_policy_id", (object?)(exception.RecheckPolicyId ?? exception.RecheckPolicy?.PolicyId) ?? DBNull.Value);
AddJsonbParameter(updateCommand, "last_recheck_result", SerializeRecheckResult(exception.LastRecheckResult));
AddParameter(updateCommand, "last_recheck_at", (object?)exception.LastRecheckAt ?? DBNull.Value);
AddParameter(updateCommand, "current_version", currentVersion);
var rows = await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -460,6 +468,9 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
AddJsonbParameter(command, "compensating_controls", JsonSerializer.Serialize(exception.CompensatingControls, JsonOptions));
AddJsonbParameter(command, "metadata", JsonSerializer.Serialize(exception.Metadata, JsonOptions));
AddParameter(command, "ticket_ref", (object?)exception.TicketRef ?? DBNull.Value);
AddParameter(command, "recheck_policy_id", (object?)(exception.RecheckPolicyId ?? exception.RecheckPolicy?.PolicyId) ?? DBNull.Value);
AddJsonbParameter(command, "last_recheck_result", SerializeRecheckResult(exception.LastRecheckResult));
AddParameter(command, "last_recheck_at", (object?)exception.LastRecheckAt ?? DBNull.Value);
}
private async Task InsertEventAsync(
@@ -618,7 +629,11 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
EvidenceRefs = ParseJsonArray(reader.GetString(reader.GetOrdinal("evidence_refs"))),
CompensatingControls = ParseJsonArray(reader.GetString(reader.GetOrdinal("compensating_controls"))),
Metadata = ParseJsonDictionary(reader.GetString(reader.GetOrdinal("metadata"))),
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref"))
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref")),
RecheckPolicyId = GetNullableString(reader, reader.GetOrdinal("recheck_policy_id")),
LastRecheckResult = ParseJsonObject<RecheckEvaluationResult>(
GetNullableString(reader, reader.GetOrdinal("last_recheck_result"))),
LastRecheckAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("last_recheck_at"))
};
}
@@ -668,6 +683,21 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
return dict?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty;
}
private static T? ParseJsonObject<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
private static string? SerializeRecheckResult(RecheckEvaluationResult? result)
{
return result is null ? null : JsonSerializer.Serialize(result, JsonOptions);
}
private static string GetScopeDescription(ExceptionScope scope)
{
var parts = new List<string>();

View File

@@ -0,0 +1,40 @@
# AGENTS.md - Policy Unknowns Library
## Purpose
- Provide deterministic ranking for unknown findings using uncertainty, exploit pressure, decay, and containment signals.
- Maintain stable, reproducible scoring and band assignment.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/policy/architecture.md
- docs/product-advisories/archived/2025-12-21-moat-gap-closure/14-Dec-2025 - Triage and Unknowns Technical Reference.md
## Working Directory
- src/Policy/__Libraries/StellaOps.Policy.Unknowns/
## Signal Sources
### BlastRadius
- Source: Scanner/Signals module call graph analysis.
- Dependents: count of packages in dependency tree.
- NetFacing: reachability from network entrypoints (HTTP controllers, gRPC, etc).
- Privilege: extracted from container config or runtime probes.
### ContainmentSignals
- Source: runtime probes (eBPF, Seccomp profiles, container inspection).
- Seccomp: profile enforcement status.
- FileSystem: mount mode from container spec or /proc/mounts.
- NetworkPolicy: Kubernetes NetworkPolicy or firewall rules.
### Data Flow
1. Scanner generates BlastRadius during SBOM or call graph analysis.
2. Runtime probes collect ContainmentSignals.
3. Signals are stored in policy.unknowns columns.
4. UnknownRanker reads signals for scoring and explainability.
## Engineering Rules
- Target net10.0 with preview features already enabled in repo.
- Determinism: stable ordering, UTC timestamps, and decimal math for scoring.
- No network dependencies inside ranking logic.

View File

@@ -0,0 +1,22 @@
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Configuration;
/// <summary>
/// Configuration options for unknown budgets.
/// </summary>
public sealed class UnknownBudgetOptions
{
public const string SectionName = "UnknownBudgets";
/// <summary>
/// Budget configurations keyed by environment name.
/// </summary>
public Dictionary<string, UnknownBudget> Budgets { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether to enforce budgets (false = warn only).
/// </summary>
public bool EnforceBudgets { get; set; } = true;
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents the dependency graph impact of an unknown package.
/// Data sourced from scanner call graph analysis.
/// </summary>
public sealed record BlastRadius
{
/// <summary>
/// Number of packages that directly or transitively depend on this package.
/// 0 indicates isolation.
/// </summary>
public int Dependents { get; init; }
/// <summary>
/// Whether this package is reachable from network-facing entrypoints.
/// True indicates higher risk.
/// </summary>
public bool NetFacing { get; init; }
/// <summary>
/// Privilege level under which this package typically runs.
/// Expected values: root, user, none.
/// </summary>
public string? Privilege { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents runtime isolation and containment posture signals.
/// Data sourced from runtime probes.
/// </summary>
public sealed record ContainmentSignals
{
/// <summary>
/// Seccomp profile status: enforced, permissive, disabled, or null if unknown.
/// </summary>
public string? Seccomp { get; init; }
/// <summary>
/// Filesystem mount mode: ro, rw, or null if unknown.
/// </summary>
public string? FileSystem { get; init; }
/// <summary>
/// Network policy status: isolated, restricted, open, or null if unknown.
/// </summary>
public string? NetworkPolicy { get; init; }
}

View File

@@ -56,6 +56,24 @@ public sealed record Unknown
/// <summary>Exploit pressure from KEV/EPSS/CVSS (0.0000 - 1.0000).</summary>
public required decimal ExploitPressure { get; init; }
/// <summary>Reason code explaining why this entry is unknown.</summary>
public required UnknownReasonCode ReasonCode { get; init; }
/// <summary>Human-readable remediation guidance for this unknown.</summary>
public string? RemediationHint { get; init; }
/// <summary>References to evidence supporting the unknown classification.</summary>
public IReadOnlyList<EvidenceRef> EvidenceRefs { get; init; } = [];
/// <summary>Assumptions applied during analysis.</summary>
public IReadOnlyList<string> Assumptions { get; init; } = [];
/// <summary>Dependency impact signals for containment reduction.</summary>
public BlastRadius? BlastRadius { get; init; }
/// <summary>Runtime containment posture signals.</summary>
public ContainmentSignals? Containment { get; init; }
/// <summary>When this unknown was first detected.</summary>
public required DateTimeOffset FirstSeenAt { get; init; }
@@ -75,6 +93,14 @@ public sealed record Unknown
public required DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Reference to evidence supporting unknown classification.
/// </summary>
public sealed record EvidenceRef(
string Type,
string Uri,
string? Digest);
/// <summary>
/// Summary counts of unknowns by band for dashboard display.
/// </summary>

View File

@@ -0,0 +1,92 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Represents an unknown budget for a specific environment.
/// Budgets define maximum acceptable unknown counts by reason code.
/// </summary>
public sealed record UnknownBudget
{
/// <summary>
/// Environment name: "prod", "stage", "dev", or custom.
/// </summary>
public required string Environment { get; init; }
/// <summary>
/// Maximum total unknowns allowed across all reason codes.
/// </summary>
public int? TotalLimit { get; init; }
/// <summary>
/// Per-reason-code limits. Missing codes inherit from TotalLimit.
/// </summary>
public IReadOnlyDictionary<UnknownReasonCode, int> ReasonLimits { get; init; }
= new Dictionary<UnknownReasonCode, int>();
/// <summary>
/// Action when budget is exceeded.
/// </summary>
public BudgetAction Action { get; init; } = BudgetAction.Warn;
/// <summary>
/// Custom message to display when budget is exceeded.
/// </summary>
public string? ExceededMessage { get; init; }
}
/// <summary>
/// Action to take when unknown budget is exceeded.
/// </summary>
public enum BudgetAction
{
/// <summary>
/// Log warning only, do not block.
/// </summary>
Warn,
/// <summary>
/// Block the operation (fail policy evaluation).
/// </summary>
Block,
/// <summary>
/// Warn but allow if exception is applied.
/// </summary>
WarnUnlessException
}
/// <summary>
/// Result of checking unknowns against a budget.
/// </summary>
public sealed record BudgetCheckResult
{
public required bool IsWithinBudget { get; init; }
public required BudgetAction RecommendedAction { get; init; }
public required int TotalUnknowns { get; init; }
public int? TotalLimit { get; init; }
public IReadOnlyDictionary<UnknownReasonCode, BudgetViolation> Violations { get; init; }
= new Dictionary<UnknownReasonCode, BudgetViolation>();
public string? Message { get; init; }
}
/// <summary>
/// Details of a specific budget violation.
/// </summary>
public sealed record BudgetViolation(
UnknownReasonCode ReasonCode,
int Count,
int Limit);
/// <summary>
/// Summary of budget status for reporting and dashboards.
/// </summary>
public sealed record BudgetStatusSummary
{
public required string Environment { get; init; }
public required int TotalUnknowns { get; init; }
public int? TotalLimit { get; init; }
public decimal PercentageUsed { get; init; }
public bool IsExceeded { get; init; }
public int ViolationCount { get; init; }
public IReadOnlyDictionary<UnknownReasonCode, int> ByReasonCode { get; init; }
= new Dictionary<UnknownReasonCode, int>();
}

View File

@@ -0,0 +1,50 @@
namespace StellaOps.Policy.Unknowns.Models;
/// <summary>
/// Canonical reason codes explaining why a component is marked as unknown.
/// Each code maps to a specific remediation action.
/// </summary>
public enum UnknownReasonCode
{
/// <summary>
/// U-RCH: Call path analysis is indeterminate.
/// The reachability analyzer cannot confirm or deny exploitability.
/// </summary>
Reachability,
/// <summary>
/// U-ID: Ambiguous package identity or missing digest.
/// Cannot uniquely identify the component (e.g., missing PURL, no checksum).
/// </summary>
Identity,
/// <summary>
/// U-PROV: Cannot map binary artifact to source repository.
/// Provenance chain is broken or unavailable.
/// </summary>
Provenance,
/// <summary>
/// U-VEX: VEX statements conflict or missing applicability data.
/// Multiple VEX sources disagree or no VEX coverage exists.
/// </summary>
VexConflict,
/// <summary>
/// U-FEED: Required knowledge source is missing or stale.
/// Advisory feed gap (e.g., no NVD/OSV data for this package).
/// </summary>
FeedGap,
/// <summary>
/// U-CONFIG: Feature flag or configuration not observable.
/// Cannot determine if vulnerable code path is enabled at runtime.
/// </summary>
ConfigUnknown,
/// <summary>
/// U-ANALYZER: Language or framework not supported by analyzer.
/// Static analysis tools do not cover this ecosystem.
/// </summary>
AnalyzerLimit
}

View File

@@ -1,4 +1,5 @@
using System.Data;
using System.Text.Json;
using Dapper;
using StellaOps.Policy.Unknowns.Models;
@@ -24,8 +25,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE id = @Id;
@@ -50,8 +56,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE package_id = @PackageId AND package_version = @PackageVersion;
@@ -76,8 +87,13 @@ public sealed class UnknownsRepository : IUnknownsRepository
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE band = @Band
@@ -122,18 +138,31 @@ public sealed class UnknownsRepository : IUnknownsRepository
SELECT set_config('app.current_tenant', @TenantId::text, true);
INSERT INTO policy.unknowns (
id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs, assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
) VALUES (
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@UncertaintyFactor, @ExploitPressure,
@ReasonCode, @RemediationHint,
@EvidenceRefs::jsonb, @Assumptions::jsonb,
@BlastRadiusDependents, @BlastRadiusNetFacing, @BlastRadiusPrivilege,
@ContainmentSeccomp, @ContainmentFsMode, @ContainmentNetworkPolicy,
@FirstSeenAt, @LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
RETURNING id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs::text as evidence_refs,
assumptions::text as assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at;
""";
@@ -147,6 +176,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = unknown.LastEvaluatedAt == default ? now : unknown.LastEvaluatedAt,
unknown.ResolutionReason,
@@ -171,6 +210,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
score = @Score,
uncertainty_factor = @UncertaintyFactor,
exploit_pressure = @ExploitPressure,
reason_code = @ReasonCode,
remediation_hint = @RemediationHint,
evidence_refs = @EvidenceRefs::jsonb,
assumptions = @Assumptions::jsonb,
blast_radius_dependents = COALESCE(@BlastRadiusDependents, blast_radius_dependents),
blast_radius_net_facing = COALESCE(@BlastRadiusNetFacing, blast_radius_net_facing),
blast_radius_privilege = COALESCE(@BlastRadiusPrivilege, blast_radius_privilege),
containment_seccomp = COALESCE(@ContainmentSeccomp, containment_seccomp),
containment_fs_mode = COALESCE(@ContainmentFsMode, containment_fs_mode),
containment_network_policy = COALESCE(@ContainmentNetworkPolicy, containment_network_policy),
last_evaluated_at = @LastEvaluatedAt,
resolution_reason = @ResolutionReason,
resolved_at = @ResolvedAt,
@@ -178,6 +227,7 @@ public sealed class UnknownsRepository : IUnknownsRepository
WHERE id = @Id;
""";
var evaluatedAt = DateTimeOffset.UtcNow;
var param = new
{
unknown.TenantId,
@@ -186,10 +236,20 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
unknown.LastEvaluatedAt,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
LastEvaluatedAt = evaluatedAt,
unknown.ResolutionReason,
unknown.ResolvedAt,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = evaluatedAt
};
var affected = await _connection.ExecuteAsync(sql, param);
@@ -240,13 +300,21 @@ public sealed class UnknownsRepository : IUnknownsRepository
SELECT set_config('app.current_tenant', @TenantId::text, true);
INSERT INTO policy.unknowns (
id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
uncertainty_factor, exploit_pressure,
reason_code, remediation_hint,
evidence_refs, assumptions,
blast_radius_dependents, blast_radius_net_facing, blast_radius_privilege,
containment_seccomp, containment_fs_mode, containment_network_policy,
first_seen_at, last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
) VALUES (
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@UncertaintyFactor, @ExploitPressure,
@ReasonCode, @RemediationHint,
@EvidenceRefs::jsonb, @Assumptions::jsonb,
@BlastRadiusDependents, @BlastRadiusNetFacing, @BlastRadiusPrivilege,
@ContainmentSeccomp, @ContainmentFsMode, @ContainmentNetworkPolicy,
@FirstSeenAt, @LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
ON CONFLICT (tenant_id, package_id, package_version)
@@ -255,6 +323,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
score = EXCLUDED.score,
uncertainty_factor = EXCLUDED.uncertainty_factor,
exploit_pressure = EXCLUDED.exploit_pressure,
reason_code = EXCLUDED.reason_code,
remediation_hint = EXCLUDED.remediation_hint,
evidence_refs = EXCLUDED.evidence_refs,
assumptions = EXCLUDED.assumptions,
blast_radius_dependents = COALESCE(EXCLUDED.blast_radius_dependents, blast_radius_dependents),
blast_radius_net_facing = COALESCE(EXCLUDED.blast_radius_net_facing, blast_radius_net_facing),
blast_radius_privilege = COALESCE(EXCLUDED.blast_radius_privilege, blast_radius_privilege),
containment_seccomp = COALESCE(EXCLUDED.containment_seccomp, containment_seccomp),
containment_fs_mode = COALESCE(EXCLUDED.containment_fs_mode, containment_fs_mode),
containment_network_policy = COALESCE(EXCLUDED.containment_network_policy, containment_network_policy),
last_evaluated_at = EXCLUDED.last_evaluated_at,
updated_at = EXCLUDED.updated_at;
""";
@@ -272,6 +350,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
ReasonCode = unknown.ReasonCode.ToString(),
unknown.RemediationHint,
EvidenceRefs = SerializeEvidenceRefs(unknown.EvidenceRefs),
Assumptions = SerializeAssumptions(unknown.Assumptions),
BlastRadiusDependents = unknown.BlastRadius?.Dependents,
BlastRadiusNetFacing = unknown.BlastRadius?.NetFacing,
BlastRadiusPrivilege = unknown.BlastRadius?.Privilege,
ContainmentSeccomp = unknown.Containment?.Seccomp,
ContainmentFsMode = unknown.Containment?.FileSystem,
ContainmentNetworkPolicy = unknown.Containment?.NetworkPolicy,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = now,
unknown.ResolutionReason,
@@ -298,6 +386,16 @@ public sealed class UnknownsRepository : IUnknownsRepository
decimal score,
decimal uncertainty_factor,
decimal exploit_pressure,
string? reason_code,
string? remediation_hint,
string? evidence_refs,
string? assumptions,
int? blast_radius_dependents,
bool? blast_radius_net_facing,
string? blast_radius_privilege,
string? containment_seccomp,
string? containment_fs_mode,
string? containment_network_policy,
DateTimeOffset first_seen_at,
DateTimeOffset last_evaluated_at,
string? resolution_reason,
@@ -315,6 +413,30 @@ public sealed class UnknownsRepository : IUnknownsRepository
Score = score,
UncertaintyFactor = uncertainty_factor,
ExploitPressure = exploit_pressure,
ReasonCode = ParseReasonCode(reason_code),
RemediationHint = remediation_hint,
EvidenceRefs = ParseEvidenceRefs(evidence_refs),
Assumptions = ParseAssumptions(assumptions),
BlastRadius = blast_radius_dependents.HasValue ||
blast_radius_net_facing.HasValue ||
!string.IsNullOrEmpty(blast_radius_privilege)
? new BlastRadius
{
Dependents = blast_radius_dependents ?? 0,
NetFacing = blast_radius_net_facing ?? false,
Privilege = blast_radius_privilege
}
: null,
Containment = !string.IsNullOrEmpty(containment_seccomp) ||
!string.IsNullOrEmpty(containment_fs_mode) ||
!string.IsNullOrEmpty(containment_network_policy)
? new ContainmentSignals
{
Seccomp = containment_seccomp,
FileSystem = containment_fs_mode,
NetworkPolicy = containment_network_policy
}
: null,
FirstSeenAt = first_seen_at,
LastEvaluatedAt = last_evaluated_at,
ResolutionReason = resolution_reason,
@@ -326,5 +448,54 @@ public sealed class UnknownsRepository : IUnknownsRepository
private sealed record SummaryRow(int hot_count, int warm_count, int cold_count, int resolved_count);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static IReadOnlyList<EvidenceRef> ParseEvidenceRefs(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<EvidenceRef>();
try
{
return JsonSerializer.Deserialize<IReadOnlyList<EvidenceRef>>(json, JsonOptions)
?? Array.Empty<EvidenceRef>();
}
catch (JsonException)
{
return Array.Empty<EvidenceRef>();
}
}
private static IReadOnlyList<string> ParseAssumptions(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return Array.Empty<string>();
try
{
return JsonSerializer.Deserialize<IReadOnlyList<string>>(json, JsonOptions)
?? Array.Empty<string>();
}
catch (JsonException)
{
return Array.Empty<string>();
}
}
private static string SerializeEvidenceRefs(IReadOnlyList<EvidenceRef>? refs) =>
JsonSerializer.Serialize(refs ?? Array.Empty<EvidenceRef>(), JsonOptions);
private static string SerializeAssumptions(IReadOnlyList<string>? assumptions) =>
JsonSerializer.Serialize(assumptions ?? Array.Empty<string>(), JsonOptions);
private static UnknownReasonCode ParseReasonCode(string? value) =>
Enum.TryParse<UnknownReasonCode>(value, ignoreCase: true, out var parsed)
? parsed
: UnknownReasonCode.Reachability;
#endregion
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Repositories;
using StellaOps.Policy.Unknowns.Services;
@@ -17,13 +18,18 @@ public static class ServiceCollectionExtensions
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddUnknownsRegistry(
this IServiceCollection services,
Action<UnknownRankerOptions>? configureOptions = null)
Action<UnknownRankerOptions>? configureOptions = null,
Action<UnknownBudgetOptions>? configureBudgetOptions = null)
{
// Configure options
if (configureOptions is not null)
services.Configure(configureOptions);
if (configureBudgetOptions is not null)
services.Configure(configureBudgetOptions);
// Register services
services.AddSingleton<IUnknownBudgetService, UnknownBudgetService>();
services.AddSingleton<IRemediationHintsRegistry, RemediationHintsRegistry>();
services.AddSingleton<IUnknownRanker, UnknownRanker>();
services.AddScoped<IUnknownsRepository, UnknownsRepository>();

View File

@@ -0,0 +1,70 @@
using System.Linq;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Services;
/// <summary>
/// Registry of remediation hints for each unknown reason code.
/// Provides actionable guidance for resolving unknowns.
/// </summary>
public sealed class RemediationHintsRegistry : IRemediationHintsRegistry
{
private static readonly IReadOnlyDictionary<UnknownReasonCode, RemediationHint> Hints =
new Dictionary<UnknownReasonCode, RemediationHint>
{
[UnknownReasonCode.Reachability] = new(
ShortHint: "Run reachability analysis",
DetailedHint: "Execute call-graph analysis to determine if vulnerable code paths are reachable from application entrypoints.",
AutomationRef: "stella analyze --reachability"),
[UnknownReasonCode.Identity] = new(
ShortHint: "Add package digest",
DetailedHint: "Ensure SBOM includes package checksums (SHA-256) and valid PURL coordinates.",
AutomationRef: "stella sbom --include-digests"),
[UnknownReasonCode.Provenance] = new(
ShortHint: "Add provenance attestation",
DetailedHint: "Generate SLSA provenance linking binary artifact to source repository and build.",
AutomationRef: "stella attest --provenance"),
[UnknownReasonCode.VexConflict] = new(
ShortHint: "Publish authoritative VEX",
DetailedHint: "Create or update VEX document with applicability assessment for your deployment context.",
AutomationRef: "stella vex create"),
[UnknownReasonCode.FeedGap] = new(
ShortHint: "Add advisory source",
DetailedHint: "Configure additional advisory feeds (OSV, vendor-specific) or request coverage from upstream.",
AutomationRef: "stella feed add"),
[UnknownReasonCode.ConfigUnknown] = new(
ShortHint: "Document feature flags",
DetailedHint: "Export runtime configuration showing which features are enabled/disabled in this deployment.",
AutomationRef: "stella config export"),
[UnknownReasonCode.AnalyzerLimit] = new(
ShortHint: "Request analyzer support",
DetailedHint: "This language/framework is not yet supported. File an issue or use manual assessment.",
AutomationRef: null)
};
public RemediationHint GetHint(UnknownReasonCode code) =>
Hints.TryGetValue(code, out var hint) ? hint : RemediationHint.Empty;
public IEnumerable<(UnknownReasonCode Code, RemediationHint Hint)> GetAllHints() =>
Hints.Select(kv => (kv.Key, kv.Value));
}
public sealed record RemediationHint(
string ShortHint,
string DetailedHint,
string? AutomationRef)
{
public static RemediationHint Empty { get; } = new("No remediation available", string.Empty, null);
}
public interface IRemediationHintsRegistry
{
RemediationHint GetHint(UnknownReasonCode code);
IEnumerable<(UnknownReasonCode Code, RemediationHint Hint)> GetAllHints();
}

View File

@@ -0,0 +1,322 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Services;
/// <summary>
/// Service for managing and checking unknown budgets.
/// </summary>
public sealed class UnknownBudgetService : IUnknownBudgetService
{
private static readonly string[] ReasonCodeMetadataKeys =
[
"unknownReasonCodes",
"unknown_reason_codes",
"unknown-reason-codes"
];
private static readonly IReadOnlyDictionary<string, UnknownReasonCode> ShortCodeMap =
new Dictionary<string, UnknownReasonCode>(StringComparer.OrdinalIgnoreCase)
{
["U-RCH"] = UnknownReasonCode.Reachability,
["U-ID"] = UnknownReasonCode.Identity,
["U-PROV"] = UnknownReasonCode.Provenance,
["U-VEX"] = UnknownReasonCode.VexConflict,
["U-FEED"] = UnknownReasonCode.FeedGap,
["U-CONFIG"] = UnknownReasonCode.ConfigUnknown,
["U-ANALYZER"] = UnknownReasonCode.AnalyzerLimit
};
private readonly IOptionsMonitor<UnknownBudgetOptions> _options;
private readonly ILogger<UnknownBudgetService> _logger;
public UnknownBudgetService(
IOptionsMonitor<UnknownBudgetOptions> options,
ILogger<UnknownBudgetService> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public UnknownBudget GetBudgetForEnvironment(string environment)
{
var normalized = NormalizeEnvironment(environment);
var budgets = _options.CurrentValue.Budgets
?? new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase);
if (budgets.TryGetValue(normalized, out var budget))
{
return NormalizeBudget(budget, normalized);
}
if (budgets.TryGetValue("default", out var defaultBudget))
{
return NormalizeBudget(defaultBudget, normalized);
}
return new UnknownBudget
{
Environment = normalized,
TotalLimit = null,
Action = BudgetAction.Warn
};
}
/// <inheritdoc />
public BudgetCheckResult CheckBudget(
string environment,
IReadOnlyList<Unknown> unknowns)
{
var normalized = NormalizeEnvironment(environment);
var budget = GetBudgetForEnvironment(normalized);
var safeUnknowns = unknowns ?? Array.Empty<Unknown>();
var byReason = CountByReason(safeUnknowns);
var violations = BuildViolations(budget, byReason);
var total = safeUnknowns.Count;
var totalExceeded = budget.TotalLimit.HasValue && total > budget.TotalLimit.Value;
var isWithinBudget = violations.Count == 0 && !totalExceeded;
var action = isWithinBudget ? BudgetAction.Warn : budget.Action;
if (!isWithinBudget && !_options.CurrentValue.EnforceBudgets)
{
action = BudgetAction.Warn;
}
var message = isWithinBudget
? null
: budget.ExceededMessage ?? $"Unknown budget exceeded: {total} unknowns in {normalized}";
return new BudgetCheckResult
{
IsWithinBudget = isWithinBudget,
RecommendedAction = action,
TotalUnknowns = total,
TotalLimit = budget.TotalLimit,
Violations = violations,
Message = message
};
}
/// <inheritdoc />
public BudgetCheckResult CheckBudgetWithEscalation(
string environment,
IReadOnlyList<Unknown> unknowns,
IReadOnlyList<ExceptionObject>? exceptions = null)
{
var normalized = NormalizeEnvironment(environment);
var baseResult = CheckBudget(normalized, unknowns);
if (baseResult.IsWithinBudget || exceptions is null || exceptions.Count == 0)
{
return baseResult;
}
var coveredReasons = CollectCoveredReasons(exceptions, normalized);
if (coveredReasons.Count == 0)
{
LogViolation(normalized, baseResult);
return baseResult;
}
var byReason = CountByReason(unknowns ?? Array.Empty<Unknown>());
var totalExceeded = baseResult.TotalLimit.HasValue && baseResult.TotalUnknowns > baseResult.TotalLimit.Value;
var violationsCovered = baseResult.Violations.Keys.All(coveredReasons.Contains);
var totalCovered = !totalExceeded || byReason.Keys.All(coveredReasons.Contains);
if (violationsCovered && totalCovered)
{
return baseResult with
{
IsWithinBudget = true,
RecommendedAction = BudgetAction.Warn,
Message = "Budget exceeded but covered by approved exceptions"
};
}
LogViolation(normalized, baseResult);
return baseResult;
}
/// <inheritdoc />
public BudgetStatusSummary GetBudgetStatus(
string environment,
IReadOnlyList<Unknown> unknowns)
{
var normalized = NormalizeEnvironment(environment);
var budget = GetBudgetForEnvironment(normalized);
var safeUnknowns = unknowns ?? Array.Empty<Unknown>();
var byReason = CountByReason(safeUnknowns);
var result = CheckBudget(normalized, safeUnknowns);
var percentage = budget.TotalLimit.HasValue && budget.TotalLimit.Value > 0
? (decimal)safeUnknowns.Count / budget.TotalLimit.Value * 100m
: 0m;
return new BudgetStatusSummary
{
Environment = normalized,
TotalUnknowns = safeUnknowns.Count,
TotalLimit = budget.TotalLimit,
PercentageUsed = percentage,
IsExceeded = !result.IsWithinBudget,
ViolationCount = result.Violations.Count,
ByReasonCode = byReason
};
}
/// <inheritdoc />
public bool ShouldBlock(BudgetCheckResult result) =>
!result.IsWithinBudget && result.RecommendedAction == BudgetAction.Block;
private static string NormalizeEnvironment(string environment) =>
string.IsNullOrWhiteSpace(environment) ? "default" : environment.Trim();
private static UnknownBudget NormalizeBudget(UnknownBudget budget, string environment)
{
var reasonLimits = budget.ReasonLimits ?? new Dictionary<UnknownReasonCode, int>();
return budget with
{
Environment = environment,
ReasonLimits = reasonLimits
};
}
private static IReadOnlyDictionary<UnknownReasonCode, int> CountByReason(IReadOnlyList<Unknown> unknowns) =>
unknowns
.GroupBy(u => u.ReasonCode)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.Count());
private static IReadOnlyDictionary<UnknownReasonCode, BudgetViolation> BuildViolations(
UnknownBudget budget,
IReadOnlyDictionary<UnknownReasonCode, int> byReason)
{
var violations = new Dictionary<UnknownReasonCode, BudgetViolation>();
foreach (var entry in budget.ReasonLimits.OrderBy(r => r.Key))
{
if (byReason.TryGetValue(entry.Key, out var count) && count > entry.Value)
{
violations[entry.Key] = new BudgetViolation(entry.Key, count, entry.Value);
}
}
return violations;
}
private static HashSet<UnknownReasonCode> CollectCoveredReasons(
IReadOnlyList<ExceptionObject> exceptions,
string environment)
{
var covered = new HashSet<UnknownReasonCode>();
foreach (var exception in exceptions)
{
if (exception.Type != ExceptionType.Unknown)
{
continue;
}
if (exception.Status is not (ExceptionStatus.Approved or ExceptionStatus.Active))
{
continue;
}
if (exception.Scope.Environments.Length > 0
&& !exception.Scope.Environments.Any(env => env.Equals(environment, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
var reasons = ParseCoveredReasonCodes(exception);
if (reasons.Count == 0)
{
foreach (var code in Enum.GetValues<UnknownReasonCode>())
{
covered.Add(code);
}
}
else
{
foreach (var code in reasons)
{
covered.Add(code);
}
}
}
return covered;
}
private static HashSet<UnknownReasonCode> ParseCoveredReasonCodes(ExceptionObject exception)
{
foreach (var key in ReasonCodeMetadataKeys)
{
if (exception.Metadata.TryGetValue(key, out var value))
{
return ParseReasonCodes(value);
}
}
return [];
}
private static HashSet<UnknownReasonCode> ParseReasonCodes(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return [];
}
var tokens = raw.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var codes = new HashSet<UnknownReasonCode>();
foreach (var token in tokens)
{
if (ShortCodeMap.TryGetValue(token, out var shortCode))
{
codes.Add(shortCode);
continue;
}
var cleaned = token.Replace("U-", "", StringComparison.OrdinalIgnoreCase).Trim();
if (Enum.TryParse(cleaned, ignoreCase: true, out UnknownReasonCode parsed))
{
codes.Add(parsed);
}
}
return codes;
}
private void LogViolation(string environment, BudgetCheckResult result)
{
if (result.IsWithinBudget)
{
return;
}
_logger.LogWarning(
"Unknown budget exceeded for environment {Environment}: {Total}/{Limit}",
environment,
result.TotalUnknowns,
result.TotalLimit?.ToString(CultureInfo.InvariantCulture) ?? "none");
}
}
public interface IUnknownBudgetService
{
UnknownBudget GetBudgetForEnvironment(string environment);
BudgetCheckResult CheckBudget(string environment, IReadOnlyList<Unknown> unknowns);
BudgetCheckResult CheckBudgetWithEscalation(
string environment,
IReadOnlyList<Unknown> unknowns,
IReadOnlyList<ExceptionObject>? exceptions = null);
BudgetStatusSummary GetBudgetStatus(string environment, IReadOnlyList<Unknown> unknowns);
bool ShouldBlock(BudgetCheckResult result);
}

View File

@@ -13,6 +13,17 @@ namespace StellaOps.Policy.Unknowns.Services;
/// <param name="IsInKev">Whether the CVE is in the CISA KEV list.</param>
/// <param name="EpssScore">EPSS score (0.0 - 1.0).</param>
/// <param name="CvssScore">CVSS base score (0.0 - 10.0).</param>
/// <param name="FirstSeenAt">When the unknown was first observed.</param>
/// <param name="LastEvaluatedAt">When the unknown was last re-ranked.</param>
/// <param name="AsOfDateTime">Reference time for decay calculations.</param>
/// <param name="BlastRadius">Dependency impact signals for containment reduction.</param>
/// <param name="Containment">Runtime containment posture signals.</param>
/// <param name="HasPackageDigest">Whether a package digest is available.</param>
/// <param name="HasProvenanceAttestation">Whether provenance attestation exists.</param>
/// <param name="HasVexConflicts">Whether VEX statements conflict.</param>
/// <param name="HasFeedCoverage">Whether advisory feeds cover this package.</param>
/// <param name="HasConfigVisibility">Whether configuration visibility is available.</param>
/// <param name="IsAnalyzerSupported">Whether analyzer supports this ecosystem.</param>
public sealed record UnknownRankInput(
bool HasVexStatement,
bool HasReachabilityData,
@@ -20,7 +31,18 @@ public sealed record UnknownRankInput(
bool IsStaleAdvisory,
bool IsInKev,
decimal EpssScore,
decimal CvssScore);
decimal CvssScore,
DateTimeOffset? FirstSeenAt,
DateTimeOffset? LastEvaluatedAt,
DateTimeOffset AsOfDateTime,
BlastRadius? BlastRadius,
ContainmentSignals? Containment,
bool HasPackageDigest,
bool HasProvenanceAttestation,
bool HasVexConflicts,
bool HasFeedCoverage,
bool HasConfigVisibility,
bool IsAnalyzerSupported);
/// <summary>
/// Result of unknown ranking calculation.
@@ -29,11 +51,19 @@ public sealed record UnknownRankInput(
/// <param name="UncertaintyFactor">Uncertainty component (0.0000 - 1.0000).</param>
/// <param name="ExploitPressure">Exploit pressure component (0.0000 - 1.0000).</param>
/// <param name="Band">Assigned band based on score thresholds.</param>
/// <param name="DecayFactor">Applied time-based decay multiplier.</param>
/// <param name="ContainmentReduction">Applied containment reduction factor.</param>
/// <param name="ReasonCode">Primary reason code for the unknown classification.</param>
/// <param name="RemediationHint">Short remediation hint for the reason code.</param>
public sealed record UnknownRankResult(
decimal Score,
decimal UncertaintyFactor,
decimal ExploitPressure,
UnknownBand Band);
UnknownBand Band,
decimal DecayFactor = 1.0m,
decimal ContainmentReduction = 0m,
UnknownReasonCode ReasonCode = UnknownReasonCode.Reachability,
string? RemediationHint = null);
/// <summary>
/// Service for computing deterministic unknown rankings.
@@ -74,9 +104,13 @@ public interface IUnknownRanker
public sealed class UnknownRanker : IUnknownRanker
{
private readonly UnknownRankerOptions _options;
private readonly IRemediationHintsRegistry _hintsRegistry;
public UnknownRanker(IOptions<UnknownRankerOptions> options)
=> _options = options.Value;
public UnknownRanker(IOptions<UnknownRankerOptions> options, IRemediationHintsRegistry? hintsRegistry = null)
{
_options = options.Value;
_hintsRegistry = hintsRegistry ?? new RemediationHintsRegistry();
}
/// <summary>
/// Default constructor for simple usage without DI.
@@ -88,10 +122,29 @@ public sealed class UnknownRanker : IUnknownRanker
{
var uncertainty = ComputeUncertainty(input);
var pressure = ComputeExploitPressure(input);
var score = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
var band = AssignBand(score);
var rawScore = Math.Round((uncertainty * 50m) + (pressure * 50m), 2);
return new UnknownRankResult(score, uncertainty, pressure, band);
var decayFactor = _options.EnableDecay ? ComputeDecayFactor(input) : 1.0m;
var decayedScore = Math.Round(rawScore * decayFactor, 2);
var containmentReduction = _options.EnableContainmentReduction
? ComputeContainmentReduction(input)
: 0m;
var finalScore = Math.Round(Math.Max(0m, decayedScore * (1m - containmentReduction)), 2);
var band = AssignBand(finalScore);
var reasonCode = DetermineReasonCode(input);
var hint = _hintsRegistry.GetHint(reasonCode);
return new UnknownRankResult(
finalScore,
uncertainty,
pressure,
band,
decayFactor,
containmentReduction,
reasonCode,
hint.ShortHint);
}
/// <summary>
@@ -144,16 +197,113 @@ public sealed class UnknownRanker : IUnknownRanker
return Math.Min(pressure, 1.0m);
}
/// <summary>
/// Computes time-based decay factor for stale unknowns.
/// </summary>
private decimal ComputeDecayFactor(UnknownRankInput input)
{
if (input.LastEvaluatedAt is null)
return 1.0m;
if (_options.DecayBuckets is null || _options.DecayBuckets.Count == 0)
return 1.0m;
var ageDays = (int)Math.Max(0, (input.AsOfDateTime - input.LastEvaluatedAt.Value).TotalDays);
DecayBucket? selected = null;
foreach (var bucket in _options.DecayBuckets)
{
if (bucket.MaxAgeDays >= ageDays &&
(selected is null || bucket.MaxAgeDays < selected.MaxAgeDays))
{
selected = bucket;
}
}
if (selected is null)
return 1.0m;
var clamped = Math.Clamp(selected.MultiplierBps, 0, 10000);
return clamped / 10000m;
}
/// <summary>
/// Computes containment-based reduction factor.
/// </summary>
private decimal ComputeContainmentReduction(UnknownRankInput input)
{
decimal reduction = 0m;
if (input.BlastRadius is { } blast)
{
if (blast.Dependents == 0)
reduction += _options.IsolatedReduction;
if (!blast.NetFacing)
reduction += _options.NotNetFacingReduction;
if (blast.Privilege is "user" or "none")
reduction += _options.NonRootReduction;
}
if (input.Containment is { } containment)
{
if (containment.Seccomp == "enforced")
reduction += _options.SeccompEnforcedReduction;
if (containment.FileSystem == "ro")
reduction += _options.FsReadOnlyReduction;
if (containment.NetworkPolicy == "isolated")
reduction += _options.NetworkIsolatedReduction;
}
return Math.Min(reduction, _options.MaxContainmentReduction);
}
/// <summary>
/// Assigns band based on score thresholds.
/// </summary>
private UnknownBand AssignBand(decimal score) => score switch
private UnknownBand AssignBand(decimal score)
{
>= 75m => UnknownBand.Hot, // Hot threshold (configurable)
>= 50m => UnknownBand.Warm, // Warm threshold
>= 25m => UnknownBand.Cold, // Cold threshold
_ => UnknownBand.Resolved // Below cold = resolved
};
if (score >= _options.HotThreshold)
return UnknownBand.Hot;
if (score >= _options.WarmThreshold)
return UnknownBand.Warm;
if (score >= _options.ColdThreshold)
return UnknownBand.Cold;
return UnknownBand.Resolved;
}
/// <summary>
/// Determines the primary reason code for unknown classification.
/// Returns the most actionable/resolvable reason.
/// </summary>
private static UnknownReasonCode DetermineReasonCode(UnknownRankInput input)
{
if (!input.IsAnalyzerSupported)
return UnknownReasonCode.AnalyzerLimit;
if (!input.HasReachabilityData)
return UnknownReasonCode.Reachability;
if (!input.HasPackageDigest)
return UnknownReasonCode.Identity;
if (!input.HasProvenanceAttestation)
return UnknownReasonCode.Provenance;
if (input.HasVexConflicts || !input.HasVexStatement)
return UnknownReasonCode.VexConflict;
if (!input.HasFeedCoverage)
return UnknownReasonCode.FeedGap;
if (!input.HasConfigVisibility)
return UnknownReasonCode.ConfigUnknown;
return UnknownReasonCode.Reachability;
}
}
/// <summary>
@@ -169,4 +319,50 @@ public sealed class UnknownRankerOptions
/// <summary>Score threshold for COLD band (default: 25).</summary>
public decimal ColdThreshold { get; set; } = 25m;
/// <summary>Enable time-based score decay.</summary>
public bool EnableDecay { get; set; } = true;
/// <summary>Decay buckets ordered by maximum age in days.</summary>
public IReadOnlyList<DecayBucket> DecayBuckets { get; set; } = DefaultDecayBuckets;
/// <summary>Default decay buckets using basis points.</summary>
public static IReadOnlyList<DecayBucket> DefaultDecayBuckets { get; } =
[
new DecayBucket(7, 10000),
new DecayBucket(30, 9000),
new DecayBucket(90, 7500),
new DecayBucket(180, 6000),
new DecayBucket(365, 4000),
new DecayBucket(int.MaxValue, 2000)
];
/// <summary>Enable containment-based reduction.</summary>
public bool EnableContainmentReduction { get; set; } = true;
/// <summary>Reduction for isolated package (dependents=0).</summary>
public decimal IsolatedReduction { get; set; } = 0.15m;
/// <summary>Reduction for not network-facing packages.</summary>
public decimal NotNetFacingReduction { get; set; } = 0.05m;
/// <summary>Reduction for non-root privilege.</summary>
public decimal NonRootReduction { get; set; } = 0.05m;
/// <summary>Reduction for enforced Seccomp.</summary>
public decimal SeccompEnforcedReduction { get; set; } = 0.10m;
/// <summary>Reduction for read-only filesystem.</summary>
public decimal FsReadOnlyReduction { get; set; } = 0.10m;
/// <summary>Reduction for isolated network policy.</summary>
public decimal NetworkIsolatedReduction { get; set; } = 0.05m;
/// <summary>Maximum reduction allowed from containment signals.</summary>
public decimal MaxContainmentReduction { get; set; } = 0.40m;
}
/// <summary>
/// Represents a decay bucket using basis points.
/// </summary>
public sealed record DecayBucket(int MaxAgeDays, int MultiplierBps);

View File

@@ -10,10 +10,12 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Npgsql" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Unknowns;
/// <summary>
/// Unknowns budget configuration for policy evaluation.
/// </summary>
public sealed record UnknownsBudgetConfig
{
/// <summary>
/// Maximum allowed critical severity unknowns.
/// </summary>
public int MaxCriticalUnknowns { get; init; } = 0;
/// <summary>
/// Maximum allowed high severity unknowns.
/// </summary>
public int MaxHighUnknowns { get; init; } = 5;
/// <summary>
/// Maximum allowed medium severity unknowns.
/// </summary>
public int MaxMediumUnknowns { get; init; } = 20;
/// <summary>
/// Maximum allowed low severity unknowns.
/// </summary>
public int MaxLowUnknowns { get; init; } = 50;
/// <summary>
/// Maximum total unknowns across all severities.
/// </summary>
public int? MaxTotalUnknowns { get; init; }
/// <summary>
/// Action to take when budget is exceeded.
/// </summary>
public UnknownsBudgetAction Action { get; init; } = UnknownsBudgetAction.Block;
/// <summary>
/// Environment-specific overrides.
/// </summary>
public Dictionary<string, UnknownsBudgetConfig>? EnvironmentOverrides { get; init; }
}
/// <summary>
/// Action to take when unknowns budget is exceeded.
/// </summary>
public enum UnknownsBudgetAction
{
/// <summary>
/// Block deployment/approval.
/// </summary>
Block,
/// <summary>
/// Warn but allow deployment.
/// </summary>
Warn,
/// <summary>
/// Log only, no enforcement.
/// </summary>
Log
}
/// <summary>
/// Counts of unknowns by severity.
/// </summary>
public sealed record UnknownsCounts
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Total => Critical + High + Medium + Low;
}
/// <summary>
/// Result of unknowns budget enforcement.
/// </summary>
public sealed record UnknownsBudgetResult
{
public required bool WithinBudget { get; init; }
public required UnknownsCounts Counts { get; init; }
public required UnknownsBudgetConfig Budget { get; init; }
public required UnknownsBudgetAction Action { get; init; }
public IReadOnlyList<string>? Violations { get; init; }
}
/// <summary>
/// Enforces unknowns budget for policy decisions.
/// </summary>
public sealed class UnknownsBudgetEnforcer
{
private readonly ILogger<UnknownsBudgetEnforcer> _logger;
public UnknownsBudgetEnforcer(ILogger<UnknownsBudgetEnforcer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Evaluate unknowns counts against budget.
/// </summary>
public UnknownsBudgetResult Evaluate(
UnknownsCounts counts,
UnknownsBudgetConfig budget,
string? environment = null)
{
ArgumentNullException.ThrowIfNull(counts);
ArgumentNullException.ThrowIfNull(budget);
var effectiveBudget = GetEffectiveBudget(budget, environment);
var violations = new List<string>();
if (counts.Critical > effectiveBudget.MaxCriticalUnknowns)
{
violations.Add($"Critical unknowns ({counts.Critical}) exceeds budget ({effectiveBudget.MaxCriticalUnknowns})");
}
if (counts.High > effectiveBudget.MaxHighUnknowns)
{
violations.Add($"High unknowns ({counts.High}) exceeds budget ({effectiveBudget.MaxHighUnknowns})");
}
if (counts.Medium > effectiveBudget.MaxMediumUnknowns)
{
violations.Add($"Medium unknowns ({counts.Medium}) exceeds budget ({effectiveBudget.MaxMediumUnknowns})");
}
if (counts.Low > effectiveBudget.MaxLowUnknowns)
{
violations.Add($"Low unknowns ({counts.Low}) exceeds budget ({effectiveBudget.MaxLowUnknowns})");
}
if (effectiveBudget.MaxTotalUnknowns.HasValue &&
counts.Total > effectiveBudget.MaxTotalUnknowns.Value)
{
violations.Add($"Total unknowns ({counts.Total}) exceeds budget ({effectiveBudget.MaxTotalUnknowns.Value})");
}
var withinBudget = violations.Count == 0;
if (!withinBudget)
{
LogViolations(violations, effectiveBudget.Action, environment);
}
return new UnknownsBudgetResult
{
WithinBudget = withinBudget,
Counts = counts,
Budget = effectiveBudget,
Action = effectiveBudget.Action,
Violations = violations
};
}
/// <summary>
/// Check if deployment should be blocked based on budget result.
/// </summary>
public bool ShouldBlock(UnknownsBudgetResult result)
{
ArgumentNullException.ThrowIfNull(result);
return !result.WithinBudget && result.Action == UnknownsBudgetAction.Block;
}
private static UnknownsBudgetConfig GetEffectiveBudget(
UnknownsBudgetConfig budget,
string? environment)
{
if (string.IsNullOrWhiteSpace(environment) ||
budget.EnvironmentOverrides is null ||
!budget.EnvironmentOverrides.TryGetValue(environment, out var override_))
{
return budget;
}
return override_;
}
private void LogViolations(
List<string> violations,
UnknownsBudgetAction action,
string? environment)
{
var envStr = string.IsNullOrWhiteSpace(environment) ? "" : $" (env: {environment})";
switch (action)
{
case UnknownsBudgetAction.Block:
_logger.LogError(
"Unknowns budget exceeded{Env}. Blocking deployment. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
case UnknownsBudgetAction.Warn:
_logger.LogWarning(
"Unknowns budget exceeded{Env}. Allowing deployment with warning. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
case UnknownsBudgetAction.Log:
_logger.LogInformation(
"Unknowns budget exceeded{Env}. Logging only. Violations: {Violations}",
envStr,
string.Join("; ", violations));
break;
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
namespace StellaOps.Policy.Confidence.Configuration;
/// <summary>
/// Configuration for confidence factor weights.
/// </summary>
public sealed class ConfidenceWeightOptions
{
public const string SectionName = "ConfidenceWeights";
/// <summary>
/// Weight for reachability factor (default: 0.30).
/// </summary>
public decimal Reachability { get; set; } = 0.30m;
/// <summary>
/// Weight for runtime corroboration (default: 0.20).
/// </summary>
public decimal Runtime { get; set; } = 0.20m;
/// <summary>
/// Weight for VEX statements (default: 0.25).
/// </summary>
public decimal Vex { get; set; } = 0.25m;
/// <summary>
/// Weight for provenance quality (default: 0.15).
/// </summary>
public decimal Provenance { get; set; } = 0.15m;
/// <summary>
/// Weight for policy match (default: 0.10).
/// </summary>
public decimal Policy { get; set; } = 0.10m;
/// <summary>
/// Minimum confidence for not_affected verdict.
/// </summary>
public decimal MinimumForNotAffected { get; set; } = 0.70m;
/// <summary>
/// Validates weights sum to 1.0.
/// </summary>
public bool Validate()
{
var sum = Reachability + Runtime + Vex + Provenance + Policy;
return Math.Abs(sum - 1.0m) < 0.001m;
}
}

View File

@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Policy.Confidence.Models;
public sealed record ReachabilityEvidence
{
public required ReachabilityState State { get; init; }
public required decimal AnalysisConfidence { get; init; }
public IReadOnlyList<string> GraphDigests { get; init; } = [];
}
public enum ReachabilityState
{
Unknown,
StaticReachable,
StaticUnreachable,
ConfirmedReachable,
ConfirmedUnreachable
}
public sealed record RuntimeEvidence
{
public required RuntimePosture Posture { get; init; }
public required int ObservationCount { get; init; }
public required DateTimeOffset LastObserved { get; init; }
public IReadOnlyList<string> SessionDigests { get; init; } = [];
public bool HasObservations => ObservationCount > 0;
public bool ObservedWithinHours(int hours, DateTimeOffset? referenceTime = null)
{
var now = referenceTime ?? DateTimeOffset.UtcNow;
if (hours <= 0 || now == DateTimeOffset.MinValue)
{
return false;
}
if (now < DateTimeOffset.MinValue.AddHours(hours))
{
return false;
}
return LastObserved > now.AddHours(-hours);
}
}
public enum RuntimePosture
{
Unknown,
Supports,
Contradicts
}
public sealed record VexEvidence
{
public required IReadOnlyList<VexStatement> Statements { get; init; }
}
public sealed record VexStatement
{
public required VexStatus Status { get; init; }
public required string Issuer { get; init; }
public required decimal TrustScore { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required string StatementDigest { get; init; }
}
public enum VexStatus
{
Affected,
NotAffected,
Fixed,
UnderInvestigation
}
public sealed record ProvenanceEvidence
{
public required ProvenanceLevel Level { get; init; }
public required decimal SbomCompleteness { get; init; }
public IReadOnlyList<string> AttestationDigests { get; init; } = [];
}
public enum ProvenanceLevel
{
Unsigned,
Signed,
SlsaLevel1,
SlsaLevel2,
SlsaLevel3
}
public sealed record PolicyEvidence
{
public required string RuleName { get; init; }
public required decimal MatchStrength { get; init; }
public required string EvaluationDigest { get; init; }
}

View File

@@ -0,0 +1,116 @@
using System.Collections.Generic;
namespace StellaOps.Policy.Confidence.Models;
/// <summary>
/// Unified confidence score aggregating all evidence types.
/// Bounded between 0.0 (no confidence) and 1.0 (full confidence).
/// </summary>
public sealed record ConfidenceScore
{
/// <summary>
/// Final aggregated confidence (0.0 - 1.0).
/// </summary>
public required decimal Value { get; init; }
/// <summary>
/// Confidence tier for quick categorization.
/// </summary>
public ConfidenceTier Tier => Value switch
{
>= 0.9m => ConfidenceTier.VeryHigh,
>= 0.7m => ConfidenceTier.High,
>= 0.5m => ConfidenceTier.Medium,
>= 0.3m => ConfidenceTier.Low,
_ => ConfidenceTier.VeryLow
};
/// <summary>
/// Breakdown of contributing factors.
/// </summary>
public required IReadOnlyList<ConfidenceFactor> Factors { get; init; }
/// <summary>
/// Human-readable explanation of the score.
/// </summary>
public required string Explanation { get; init; }
/// <summary>
/// What would improve this confidence score.
/// </summary>
public IReadOnlyList<ConfidenceImprovement> Improvements { get; init; } = [];
}
/// <summary>
/// A single factor contributing to confidence.
/// </summary>
public sealed record ConfidenceFactor
{
/// <summary>
/// Factor type (reachability, runtime, vex, provenance, policy).
/// </summary>
public required ConfidenceFactorType Type { get; init; }
/// <summary>
/// Weight of this factor in aggregation (0.0 - 1.0).
/// </summary>
public required decimal Weight { get; init; }
/// <summary>
/// Raw value before weighting (0.0 - 1.0).
/// </summary>
public required decimal RawValue { get; init; }
/// <summary>
/// Weighted contribution to final score.
/// </summary>
public decimal Contribution => Weight * RawValue;
/// <summary>
/// Human-readable reason for this value.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Evidence digests supporting this factor.
/// </summary>
public IReadOnlyList<string> EvidenceDigests { get; init; } = [];
}
public enum ConfidenceFactorType
{
/// <summary>Call graph reachability analysis.</summary>
Reachability,
/// <summary>Runtime corroboration (eBPF, dyld, ETW).</summary>
Runtime,
/// <summary>VEX statement from vendor/distro.</summary>
Vex,
/// <summary>Build provenance and SBOM quality.</summary>
Provenance,
/// <summary>Policy rule match strength.</summary>
Policy,
/// <summary>Advisory freshness and source quality.</summary>
Advisory
}
public enum ConfidenceTier
{
VeryLow,
Low,
Medium,
High,
VeryHigh
}
/// <summary>
/// Actionable improvement to increase confidence.
/// </summary>
public sealed record ConfidenceImprovement(
ConfidenceFactorType Factor,
string Action,
decimal PotentialGain);

View File

@@ -0,0 +1,363 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Confidence.Configuration;
using StellaOps.Policy.Confidence.Models;
namespace StellaOps.Policy.Confidence.Services;
public interface IConfidenceCalculator
{
ConfidenceScore Calculate(ConfidenceInput input);
}
public sealed class ConfidenceCalculator : IConfidenceCalculator
{
private readonly IOptionsMonitor<ConfidenceWeightOptions> _options;
public ConfidenceCalculator(IOptionsMonitor<ConfidenceWeightOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public ConfidenceScore Calculate(ConfidenceInput input)
{
ArgumentNullException.ThrowIfNull(input);
var weights = NormalizeWeights(_options.CurrentValue);
var factors = new List<ConfidenceFactor>(capacity: 5)
{
CalculateReachabilityFactor(input.Reachability, weights.Reachability),
CalculateRuntimeFactor(input.Runtime, weights.Runtime, input.EvaluationTimestamp),
CalculateVexFactor(input.Vex, weights.Vex),
CalculateProvenanceFactor(input.Provenance, weights.Provenance),
CalculatePolicyFactor(input.Policy, weights.Policy)
};
var totalValue = factors.Sum(f => f.Contribution);
var clampedValue = Clamp01(totalValue);
var explanation = GenerateExplanation(factors, clampedValue);
var improvements = GenerateImprovements(factors, weights, input.Status, clampedValue);
return new ConfidenceScore
{
Value = clampedValue,
Factors = factors,
Explanation = explanation,
Improvements = improvements
};
}
private static ConfidenceFactor CalculateReachabilityFactor(ReachabilityEvidence? evidence, decimal weight)
{
if (evidence is null)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Reachability,
Weight = weight,
RawValue = 0.5m,
Reason = "No reachability analysis performed",
EvidenceDigests = []
};
}
var baseValue = evidence.State switch
{
ReachabilityState.ConfirmedUnreachable => 1.0m,
ReachabilityState.StaticUnreachable => 0.85m,
ReachabilityState.Unknown => 0.5m,
ReachabilityState.StaticReachable => 0.3m,
ReachabilityState.ConfirmedReachable => 0.1m,
_ => 0.5m
};
var rawValue = Clamp01(baseValue * Clamp01(evidence.AnalysisConfidence));
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Reachability,
Weight = weight,
RawValue = rawValue,
Reason = $"Reachability: {evidence.State} (analysis confidence: {Clamp01(evidence.AnalysisConfidence):P0})",
EvidenceDigests = evidence.GraphDigests.ToList()
};
}
private static ConfidenceFactor CalculateRuntimeFactor(
RuntimeEvidence? evidence,
decimal weight,
DateTimeOffset? evaluationTimestamp)
{
if (evidence is null || !evidence.HasObservations)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Runtime,
Weight = weight,
RawValue = 0.5m,
Reason = "No runtime observations available",
EvidenceDigests = []
};
}
var rawValue = evidence.Posture switch
{
RuntimePosture.Supports => 0.9m,
RuntimePosture.Contradicts => 0.2m,
RuntimePosture.Unknown => 0.5m,
_ => 0.5m
};
var recencyBonus = evidence.ObservedWithinHours(24, evaluationTimestamp) ? 0.1m : 0m;
rawValue = Clamp01(rawValue + recencyBonus);
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Runtime,
Weight = weight,
RawValue = rawValue,
Reason = $"Runtime {evidence.Posture.ToString().ToLowerInvariant()}: {evidence.ObservationCount} observations",
EvidenceDigests = evidence.SessionDigests.ToList()
};
}
private static ConfidenceFactor CalculateVexFactor(VexEvidence? evidence, decimal weight)
{
if (evidence is null || evidence.Statements.Count == 0)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Vex,
Weight = weight,
RawValue = 0.5m,
Reason = "No VEX statements available",
EvidenceDigests = []
};
}
var best = evidence.Statements
.OrderByDescending(s => s.TrustScore)
.ThenByDescending(s => s.Timestamp)
.ThenBy(s => s.StatementDigest, StringComparer.Ordinal)
.First();
var rawValue = best.Status switch
{
VexStatus.NotAffected => Clamp01(best.TrustScore),
VexStatus.Fixed => Clamp01(best.TrustScore * 0.9m),
VexStatus.UnderInvestigation => 0.4m,
VexStatus.Affected => 0.1m,
_ => 0.5m
};
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Vex,
Weight = weight,
RawValue = rawValue,
Reason = $"VEX {best.Status} from {best.Issuer} (trust: {Clamp01(best.TrustScore):P0})",
EvidenceDigests = [best.StatementDigest]
};
}
private static ConfidenceFactor CalculateProvenanceFactor(ProvenanceEvidence? evidence, decimal weight)
{
if (evidence is null)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Provenance,
Weight = weight,
RawValue = 0.3m,
Reason = "No provenance information",
EvidenceDigests = []
};
}
var rawValue = evidence.Level switch
{
ProvenanceLevel.SlsaLevel3 => 1.0m,
ProvenanceLevel.SlsaLevel2 => 0.85m,
ProvenanceLevel.SlsaLevel1 => 0.7m,
ProvenanceLevel.Signed => 0.6m,
ProvenanceLevel.Unsigned => 0.3m,
_ => 0.3m
};
if (Clamp01(evidence.SbomCompleteness) >= 0.9m)
{
rawValue = Clamp01(rawValue + 0.1m);
}
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Provenance,
Weight = weight,
RawValue = rawValue,
Reason = $"Provenance: {evidence.Level}, SBOM completeness: {Clamp01(evidence.SbomCompleteness):P0}",
EvidenceDigests = evidence.AttestationDigests.ToList()
};
}
private static ConfidenceFactor CalculatePolicyFactor(PolicyEvidence? evidence, decimal weight)
{
if (evidence is null)
{
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Policy,
Weight = weight,
RawValue = 0.5m,
Reason = "No policy evaluation",
EvidenceDigests = []
};
}
var rawValue = Clamp01(evidence.MatchStrength);
return new ConfidenceFactor
{
Type = ConfidenceFactorType.Policy,
Weight = weight,
RawValue = rawValue,
Reason = $"Policy rule '{evidence.RuleName}' matched (strength: {rawValue:P0})",
EvidenceDigests = [evidence.EvaluationDigest]
};
}
private static string GenerateExplanation(IReadOnlyList<ConfidenceFactor> factors, decimal totalValue)
{
var tier = totalValue switch
{
>= 0.9m => "very high",
>= 0.7m => "high",
>= 0.5m => "medium",
>= 0.3m => "low",
_ => "very low"
};
var topFactors = factors
.OrderByDescending(f => f.Contribution)
.ThenBy(f => f.Type)
.Take(2)
.Select(f => f.Type.ToString().ToLowerInvariant())
.ToArray();
if (topFactors.Length == 0)
{
return $"Confidence is {tier} ({totalValue:P0}).";
}
return $"Confidence is {tier} ({totalValue:P0}), primarily driven by {string.Join(" and ", topFactors)}.";
}
private static IReadOnlyList<ConfidenceImprovement> GenerateImprovements(
IReadOnlyList<ConfidenceFactor> factors,
ConfidenceWeightOptions weights,
string? status,
decimal totalValue)
{
var improvements = new List<ConfidenceImprovement>();
foreach (var factor in factors.Where(f => f.RawValue < 0.7m))
{
var (action, potentialGain) = factor.Type switch
{
ConfidenceFactorType.Reachability =>
("Run deeper reachability analysis", factor.Weight * 0.3m),
ConfidenceFactorType.Runtime =>
("Deploy runtime sensor and collect observations", factor.Weight * 0.4m),
ConfidenceFactorType.Vex =>
("Obtain VEX statement from vendor", factor.Weight * 0.4m),
ConfidenceFactorType.Provenance =>
("Add SLSA provenance attestation", factor.Weight * 0.3m),
ConfidenceFactorType.Policy =>
("Review and refine policy rules", factor.Weight * 0.2m),
_ => ("Gather additional evidence", 0.1m)
};
improvements.Add(new ConfidenceImprovement(factor.Type, action, potentialGain));
}
if (IsNotAffected(status) && weights.MinimumForNotAffected > 0m && totalValue < weights.MinimumForNotAffected)
{
improvements.Add(new ConfidenceImprovement(
ConfidenceFactorType.Policy,
$"Increase evidence to reach {weights.MinimumForNotAffected:P0} confidence for not_affected",
Clamp01(weights.MinimumForNotAffected - totalValue)));
}
return improvements
.OrderByDescending(i => i.PotentialGain)
.ThenBy(i => i.Factor)
.Take(3)
.ToList();
}
private static bool IsNotAffected(string? status)
{
return status != null
&& status.Equals("not_affected", StringComparison.OrdinalIgnoreCase);
}
private static ConfidenceWeightOptions NormalizeWeights(ConfidenceWeightOptions input)
{
if (input is null)
{
return new ConfidenceWeightOptions();
}
var sum = input.Reachability + input.Runtime + input.Vex + input.Provenance + input.Policy;
if (sum <= 0m)
{
return new ConfidenceWeightOptions();
}
if (Math.Abs(sum - 1.0m) < 0.001m)
{
return input;
}
return new ConfidenceWeightOptions
{
Reachability = input.Reachability / sum,
Runtime = input.Runtime / sum,
Vex = input.Vex / sum,
Provenance = input.Provenance / sum,
Policy = input.Policy / sum,
MinimumForNotAffected = input.MinimumForNotAffected
};
}
private static decimal Clamp01(decimal value)
{
if (value <= 0m)
{
return 0m;
}
if (value >= 1m)
{
return 1m;
}
return value;
}
}
/// <summary>
/// Input container for confidence calculation.
/// </summary>
public sealed record ConfidenceInput
{
public ReachabilityEvidence? Reachability { get; init; }
public RuntimeEvidence? Runtime { get; init; }
public VexEvidence? Vex { get; init; }
public ProvenanceEvidence? Provenance { get; init; }
public PolicyEvidence? Policy { get; init; }
public string? Status { get; init; }
public DateTimeOffset? EvaluationTimestamp { get; init; }
}

View File

@@ -0,0 +1,55 @@
namespace StellaOps.Policy.Counterfactuals;
/// <summary>
/// Result of counterfactual analysis - what would flip the verdict.
/// </summary>
public sealed record CounterfactualResult
{
public required Guid FindingId { get; init; }
public required string CurrentVerdict { get; init; }
public required string TargetVerdict { get; init; }
public required IReadOnlyList<CounterfactualPath> Paths { get; init; }
public bool HasPaths => Paths.Count > 0;
public CounterfactualPath? RecommendedPath =>
Paths.OrderBy(path => path.EstimatedEffort).FirstOrDefault();
}
/// <summary>
/// A single path that would flip the verdict.
/// </summary>
public sealed record CounterfactualPath
{
public required CounterfactualType Type { get; init; }
public required string Description { get; init; }
public required IReadOnlyList<CounterfactualCondition> Conditions { get; init; }
public int EstimatedEffort { get; init; }
public required string Actor { get; init; }
public string? ActionUri { get; init; }
}
/// <summary>
/// A specific condition in a counterfactual path.
/// </summary>
public sealed record CounterfactualCondition
{
public required string Field { get; init; }
public required string CurrentValue { get; init; }
public required string RequiredValue { get; init; }
public bool IsMet { get; init; }
}
/// <summary>
/// Type of counterfactual change.
/// </summary>
public enum CounterfactualType
{
VexStatus,
Exception,
Reachability,
VersionUpgrade,
PolicyChange,
ComponentRemoval,
CompensatingControl,
}

View File

@@ -0,0 +1,202 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Freshness;
public interface IEvidenceTtlEnforcer
{
/// <summary>
/// Checks freshness of all evidence in a bundle.
/// </summary>
EvidenceFreshnessResult CheckFreshness(EvidenceBundle bundle, DateTimeOffset asOf);
/// <summary>
/// Gets TTL for a specific evidence type.
/// </summary>
TimeSpan GetTtl(EvidenceType type);
/// <summary>
/// Computes expiration time for evidence.
/// </summary>
DateTimeOffset ComputeExpiration(EvidenceType type, DateTimeOffset createdAt);
}
public sealed class EvidenceTtlEnforcer : IEvidenceTtlEnforcer
{
private readonly EvidenceTtlOptions _options;
private readonly ILogger<EvidenceTtlEnforcer> _logger;
public EvidenceTtlEnforcer(
IOptions<EvidenceTtlOptions> options,
ILogger<EvidenceTtlEnforcer> logger)
{
_options = options.Value;
_logger = logger;
}
public EvidenceFreshnessResult CheckFreshness(EvidenceBundle bundle, DateTimeOffset asOf)
{
var checks = new List<EvidenceFreshnessCheck>();
// Check each evidence type
if (bundle.Reachability is not null)
{
checks.Add(CheckType(EvidenceType.Reachability, bundle.Reachability.ComputedAt, asOf));
}
if (bundle.CallStack is not null)
{
checks.Add(CheckType(EvidenceType.CallStack, bundle.CallStack.CapturedAt, asOf));
}
if (bundle.VexStatus is not null)
{
checks.Add(CheckType(EvidenceType.Vex, bundle.VexStatus.Timestamp, asOf));
}
if (bundle.Provenance is not null)
{
checks.Add(CheckType(EvidenceType.Sbom, bundle.Provenance.BuildTime, asOf));
}
if (bundle.Boundary is not null)
{
checks.Add(CheckType(EvidenceType.Boundary, bundle.Boundary.ObservedAt, asOf));
}
// Determine overall status
var anyStale = checks.Any(c => c.Status == FreshnessStatus.Stale);
var anyWarning = checks.Any(c => c.Status == FreshnessStatus.Warning);
return new EvidenceFreshnessResult
{
OverallStatus = anyStale ? FreshnessStatus.Stale
: anyWarning ? FreshnessStatus.Warning
: FreshnessStatus.Fresh,
Checks = checks,
RecommendedAction = anyStale ? _options.StaleAction : StaleEvidenceAction.Warn,
CheckedAt = asOf
};
}
private EvidenceFreshnessCheck CheckType(
EvidenceType type,
DateTimeOffset createdAt,
DateTimeOffset asOf)
{
var ttl = GetTtl(type);
var expiresAt = createdAt + ttl;
var remaining = expiresAt - asOf;
var warningThreshold = ttl * _options.WarningThresholdPercent;
FreshnessStatus status;
if (remaining <= TimeSpan.Zero)
{
status = FreshnessStatus.Stale;
}
else if (remaining <= warningThreshold)
{
status = FreshnessStatus.Warning;
}
else
{
status = FreshnessStatus.Fresh;
}
return new EvidenceFreshnessCheck
{
Type = type,
CreatedAt = createdAt,
ExpiresAt = expiresAt,
Ttl = ttl,
Remaining = remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero,
Status = status,
Message = status switch
{
FreshnessStatus.Stale => $"{type} evidence expired {-remaining.TotalHours:F0}h ago",
FreshnessStatus.Warning => $"{type} evidence expires in {remaining.TotalHours:F0}h",
_ => $"{type} evidence fresh ({remaining.TotalDays:F0}d remaining)"
}
};
}
public TimeSpan GetTtl(EvidenceType type)
{
return type switch
{
EvidenceType.Sbom => _options.SbomTtl,
EvidenceType.Reachability => _options.ReachabilityTtl,
EvidenceType.Boundary => _options.BoundaryTtl,
EvidenceType.Vex => _options.VexTtl,
EvidenceType.PolicyDecision => _options.PolicyDecisionTtl,
EvidenceType.HumanApproval => _options.HumanApprovalTtl,
EvidenceType.CallStack => _options.ReachabilityTtl,
_ => TimeSpan.FromDays(7)
};
}
public DateTimeOffset ComputeExpiration(EvidenceType type, DateTimeOffset createdAt)
{
return createdAt + GetTtl(type);
}
}
public sealed record EvidenceFreshnessResult
{
public required FreshnessStatus OverallStatus { get; init; }
public required IReadOnlyList<EvidenceFreshnessCheck> Checks { get; init; }
public required StaleEvidenceAction RecommendedAction { get; init; }
public required DateTimeOffset CheckedAt { get; init; }
public bool IsAcceptable => OverallStatus != FreshnessStatus.Stale;
public bool HasWarnings => OverallStatus == FreshnessStatus.Warning;
}
public sealed record EvidenceFreshnessCheck
{
public required EvidenceType Type { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required TimeSpan Ttl { get; init; }
public required TimeSpan Remaining { get; init; }
public required FreshnessStatus Status { get; init; }
public required string Message { get; init; }
}
/// <summary>
/// Evidence bundle placeholder - reference to actual evidence models.
/// In practice, this would be replaced with actual evidence bundle from Scanner/Attestor modules.
/// </summary>
public sealed record EvidenceBundle
{
public ReachabilityEvidence? Reachability { get; init; }
public CallStackEvidence? CallStack { get; init; }
public VexEvidence? VexStatus { get; init; }
public ProvenanceEvidence? Provenance { get; init; }
public BoundaryEvidence? Boundary { get; init; }
}
public sealed record ReachabilityEvidence
{
public required DateTimeOffset ComputedAt { get; init; }
}
public sealed record CallStackEvidence
{
public required DateTimeOffset CapturedAt { get; init; }
}
public sealed record VexEvidence
{
public required DateTimeOffset Timestamp { get; init; }
}
public sealed record ProvenanceEvidence
{
public required DateTimeOffset BuildTime { get; init; }
}
public sealed record BoundaryEvidence
{
public required DateTimeOffset ObservedAt { get; init; }
}

View File

@@ -0,0 +1,99 @@
namespace StellaOps.Policy.Freshness;
/// <summary>
/// TTL configuration per evidence type.
/// </summary>
public sealed class EvidenceTtlOptions
{
/// <summary>
/// SBOM evidence TTL. Long because digest is immutable.
/// Default: 30 days.
/// </summary>
public TimeSpan SbomTtl { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Boundary evidence TTL. Short because environment changes.
/// Default: 72 hours.
/// </summary>
public TimeSpan BoundaryTtl { get; set; } = TimeSpan.FromHours(72);
/// <summary>
/// Reachability evidence TTL. Medium based on code churn.
/// Default: 7 days.
/// </summary>
public TimeSpan ReachabilityTtl { get; set; } = TimeSpan.FromDays(7);
/// <summary>
/// VEX evidence TTL. Renew on boundary/reachability change.
/// Default: 14 days.
/// </summary>
public TimeSpan VexTtl { get; set; } = TimeSpan.FromDays(14);
/// <summary>
/// Policy decision TTL.
/// Default: 24 hours.
/// </summary>
public TimeSpan PolicyDecisionTtl { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Human approval TTL.
/// Default: 30 days.
/// </summary>
public TimeSpan HumanApprovalTtl { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Warning threshold as percentage of TTL remaining.
/// Default: 20% (warn when 80% of TTL elapsed).
/// </summary>
public double WarningThresholdPercent { get; set; } = 0.20;
/// <summary>
/// Action when evidence is stale.
/// </summary>
public StaleEvidenceAction StaleAction { get; set; } = StaleEvidenceAction.Warn;
}
/// <summary>
/// Action to take when evidence is stale.
/// </summary>
public enum StaleEvidenceAction
{
/// <summary>
/// Allow but log warning.
/// </summary>
Warn,
/// <summary>
/// Block the decision.
/// </summary>
Block,
/// <summary>
/// Degrade confidence score.
/// </summary>
DegradeConfidence
}
/// <summary>
/// Evidence type for TTL enforcement.
/// </summary>
public enum EvidenceType
{
Sbom,
Reachability,
Boundary,
Vex,
PolicyDecision,
HumanApproval,
CallStack
}
/// <summary>
/// Freshness status for evidence.
/// </summary>
public enum FreshnessStatus
{
Fresh,
Warning,
Stale
}

View File

@@ -0,0 +1,99 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Freshness;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Policy gate that enforces evidence TTL requirements.
/// Blocks, warns, or degrades confidence based on evidence staleness.
/// </summary>
public sealed class EvidenceFreshnessGate : IPolicyGate
{
private readonly IEvidenceTtlEnforcer _ttlEnforcer;
private readonly ILogger<EvidenceFreshnessGate> _logger;
public EvidenceFreshnessGate(
IEvidenceTtlEnforcer ttlEnforcer,
ILogger<EvidenceFreshnessGate> logger)
{
_ttlEnforcer = ttlEnforcer;
_logger = logger;
}
public Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
// Build evidence bundle from context
var evidenceBundle = BuildEvidenceBundleFromContext(context);
var freshnessResult = _ttlEnforcer.CheckFreshness(evidenceBundle, DateTimeOffset.UtcNow);
var details = ImmutableDictionary.CreateBuilder<string, object>();
details.Add("overall_status", freshnessResult.OverallStatus.ToString());
details.Add("recommended_action", freshnessResult.RecommendedAction.ToString());
details.Add("checked_at", freshnessResult.CheckedAt);
details.Add("checks", freshnessResult.Checks.Select(c => new
{
type = c.Type.ToString(),
status = c.Status.ToString(),
expires_at = c.ExpiresAt,
remaining_hours = c.Remaining.TotalHours,
message = c.Message
}).ToList());
// Determine pass/fail based on recommended action
var passed = freshnessResult.OverallStatus switch
{
FreshnessStatus.Fresh => true,
FreshnessStatus.Warning => true, // Warnings don't block by default
FreshnessStatus.Stale when freshnessResult.RecommendedAction == StaleEvidenceAction.Warn => true,
FreshnessStatus.Stale when freshnessResult.RecommendedAction == StaleEvidenceAction.DegradeConfidence => true,
FreshnessStatus.Stale when freshnessResult.RecommendedAction == StaleEvidenceAction.Block => false,
_ => true
};
var reason = passed
? freshnessResult.HasWarnings
? $"Evidence approaching expiration: {string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Warning).Select(c => c.Type))}"
: null
: $"Stale evidence detected: {string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Stale).Select(c => c.Type))}";
if (!passed)
{
_logger.LogWarning(
"Evidence freshness gate failed: {Reason}. Stale evidence: {StaleTypes}",
reason,
string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Stale).Select(c => c.Type)));
}
else if (freshnessResult.HasWarnings)
{
_logger.LogInformation(
"Evidence freshness warning: {WarningTypes}",
string.Join(", ", freshnessResult.Checks.Where(c => c.Status == FreshnessStatus.Warning).Select(c => c.Type)));
}
return Task.FromResult(new GateResult
{
GateName = "EvidenceFreshness",
Passed = passed,
Reason = reason,
Details = details.ToImmutable()
});
}
private static EvidenceBundle BuildEvidenceBundleFromContext(PolicyGateContext context)
{
// In a real implementation, this would extract evidence metadata from the context
// For now, return a minimal bundle
// This should be extended when evidence metadata is added to PolicyGateContext
return new EvidenceBundle
{
// Evidence would be populated from context metadata
// This is a placeholder until PolicyGateContext is extended with evidence timestamps
};
}
}

View File

@@ -0,0 +1,87 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.Gates;
public sealed record MinimumConfidenceGateOptions
{
public bool Enabled { get; init; } = true;
public IReadOnlyDictionary<string, double> Thresholds { get; init; } = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
["production"] = 0.75,
["staging"] = 0.60,
["development"] = 0.40,
};
public IReadOnlyCollection<VexStatus> ApplyToStatuses { get; init; } = new[]
{
VexStatus.NotAffected,
VexStatus.Fixed,
};
}
public sealed class MinimumConfidenceGate : IPolicyGate
{
private readonly MinimumConfidenceGateOptions _options;
public MinimumConfidenceGate(MinimumConfidenceGateOptions? options = null)
{
_options = options ?? new MinimumConfidenceGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
if (mergeResult.Status == VexStatus.Affected)
{
return Task.FromResult(Pass("affected_bypass"));
}
if (!_options.ApplyToStatuses.Contains(mergeResult.Status))
{
return Task.FromResult(Pass("status_not_applicable"));
}
var threshold = GetThreshold(context.Environment);
var passed = mergeResult.Confidence >= threshold;
var details = ImmutableDictionary<string, object>.Empty
.Add("threshold", threshold)
.Add("confidence", mergeResult.Confidence)
.Add("environment", context.Environment);
return Task.FromResult(new GateResult
{
GateName = nameof(MinimumConfidenceGate),
Passed = passed,
Reason = passed ? null : "confidence_below_threshold",
Details = details,
});
}
private double GetThreshold(string environment)
{
if (_options.Thresholds.TryGetValue(environment, out var threshold))
{
return threshold;
}
if (_options.Thresholds.TryGetValue("production", out var prod))
{
return prod;
}
return 0.0;
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(MinimumConfidenceGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
public sealed record PolicyGateContext
{
public string Environment { get; init; } = "production";
public int UnknownCount { get; init; }
public IReadOnlyList<double> UnknownClaimScores { get; init; } = Array.Empty<double>();
public IReadOnlyDictionary<string, double> SourceInfluence { get; init; } = new Dictionary<string, double>(StringComparer.Ordinal);
public bool HasReachabilityProof { get; init; }
public string? Severity { get; init; }
public IReadOnlyCollection<string> ReasonCodes { get; init; } = Array.Empty<string>();
}
public sealed record GateResult
{
public required string GateName { get; init; }
public required bool Passed { get; init; }
public required string? Reason { get; init; }
public required ImmutableDictionary<string, object> Details { get; init; }
}
public sealed record GateEvaluationResult
{
public required bool AllPassed { get; init; }
public required ImmutableArray<GateResult> Results { get; init; }
public GateResult? FirstFailure => Results.FirstOrDefault(r => !r.Passed);
}
public interface IPolicyGate
{
Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default);
}
public sealed record PolicyGateRegistryOptions
{
public bool StopOnFirstFailure { get; init; } = true;
}
public interface IPolicyGateRegistry
{
void Register<TGate>(string name) where TGate : IPolicyGate;
Task<GateEvaluationResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,68 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Gates;
public sealed class PolicyGateRegistry : IPolicyGateRegistry
{
private readonly IServiceProvider _serviceProvider;
private readonly PolicyGateRegistryOptions _options;
private readonly List<GateDescriptor> _gates = new();
public PolicyGateRegistry(IServiceProvider serviceProvider, PolicyGateRegistryOptions? options = null)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_options = options ?? new PolicyGateRegistryOptions();
}
public void Register<TGate>(string name) where TGate : IPolicyGate
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Gate name must be provided.", nameof(name));
}
_gates.Add(new GateDescriptor(name, typeof(TGate)));
}
public async Task<GateEvaluationResult> EvaluateAsync(
TrustLattice.MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(mergeResult);
ArgumentNullException.ThrowIfNull(context);
var results = new List<GateResult>();
foreach (var gate in _gates)
{
var instance = _serviceProvider.GetService(gate.Type) as IPolicyGate
?? (IPolicyGate)Activator.CreateInstance(gate.Type)!;
var result = await instance.EvaluateAsync(mergeResult, context, ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(result.GateName))
{
result = result with { GateName = gate.Name };
}
if (result.Details is null)
{
result = result with { Details = ImmutableDictionary<string, object>.Empty };
}
results.Add(result);
if (!result.Passed && _options.StopOnFirstFailure)
{
break;
}
}
return new GateEvaluationResult
{
AllPassed = results.All(r => r.Passed),
Results = results.ToImmutableArray(),
};
}
private sealed record GateDescriptor(string Name, Type Type);
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.Gates;
public sealed record ReachabilityRequirementGateOptions
{
public bool Enabled { get; init; } = true;
public string SeverityThreshold { get; init; } = "CRITICAL";
public IReadOnlyCollection<VexStatus> RequiredForStatuses { get; init; } = new[]
{
VexStatus.NotAffected,
};
public IReadOnlyCollection<string> BypassReasons { get; init; } = new[]
{
"component_not_present",
"vulnerable_configuration_unused",
};
}
public sealed class ReachabilityRequirementGate : IPolicyGate
{
private readonly ReachabilityRequirementGateOptions _options;
public ReachabilityRequirementGate(ReachabilityRequirementGateOptions? options = null)
{
_options = options ?? new ReachabilityRequirementGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
if (!_options.RequiredForStatuses.Contains(mergeResult.Status))
{
return Task.FromResult(Pass("status_not_applicable"));
}
var severityRank = SeverityRank(context.Severity);
var thresholdRank = SeverityRank(_options.SeverityThreshold);
if (severityRank < thresholdRank)
{
return Task.FromResult(Pass("severity_below_threshold"));
}
if (HasBypass(context.ReasonCodes))
{
return Task.FromResult(Pass("bypass_reason"));
}
var passed = context.HasReachabilityProof;
var details = ImmutableDictionary<string, object>.Empty
.Add("severity", context.Severity ?? string.Empty)
.Add("threshold", _options.SeverityThreshold)
.Add("hasReachabilityProof", context.HasReachabilityProof);
return Task.FromResult(new GateResult
{
GateName = nameof(ReachabilityRequirementGate),
Passed = passed,
Reason = passed ? null : "reachability_proof_missing",
Details = details,
});
}
private bool HasBypass(IReadOnlyCollection<string> reasons)
=> reasons.Any(reason => _options.BypassReasons.Contains(reason, StringComparer.OrdinalIgnoreCase));
private static int SeverityRank(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return 0;
}
return severity.Trim().ToUpperInvariant() switch
{
"CRITICAL" => 4,
"HIGH" => 3,
"MEDIUM" => 2,
"LOW" => 1,
_ => 0,
};
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(ReachabilityRequirementGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.Gates;
public sealed record SourceQuotaGateOptions
{
public bool Enabled { get; init; } = true;
public double MaxInfluencePercent { get; init; } = 60;
public double CorroborationDelta { get; init; } = 0.10;
public IReadOnlyCollection<VexStatus> RequireCorroborationFor { get; init; } = new[]
{
VexStatus.NotAffected,
VexStatus.Fixed,
};
}
public sealed class SourceQuotaGate : IPolicyGate
{
private readonly SourceQuotaGateOptions _options;
public SourceQuotaGate(SourceQuotaGateOptions? options = null)
{
_options = options ?? new SourceQuotaGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
if (!_options.RequireCorroborationFor.Contains(mergeResult.Status))
{
return Task.FromResult(Pass("status_not_applicable"));
}
var influence = context.SourceInfluence.Count > 0
? context.SourceInfluence
: ComputeInfluence(mergeResult);
if (influence.Count == 0)
{
return Task.FromResult(Pass("no_sources"));
}
var maxAllowed = _options.MaxInfluencePercent / 100.0;
var ordered = influence.OrderByDescending(kv => kv.Value).ToList();
var top = ordered[0];
var second = ordered.Count > 1 ? ordered[1] : new KeyValuePair<string, double>(string.Empty, 0);
var corroborated = ordered.Count > 1 && (top.Value - second.Value) <= _options.CorroborationDelta;
var passed = top.Value <= maxAllowed || corroborated;
var details = ImmutableDictionary<string, object>.Empty
.Add("maxInfluence", maxAllowed)
.Add("topSource", top.Key)
.Add("topShare", top.Value)
.Add("secondShare", second.Value)
.Add("corroborated", corroborated);
return Task.FromResult(new GateResult
{
GateName = nameof(SourceQuotaGate),
Passed = passed,
Reason = passed ? null : "source_quota_exceeded",
Details = details,
});
}
private static IReadOnlyDictionary<string, double> ComputeInfluence(MergeResult mergeResult)
{
var relevant = mergeResult.AllClaims.Where(c => c.Status == mergeResult.Status).ToList();
var total = relevant.Sum(c => c.AdjustedScore);
if (total <= 0)
{
return new Dictionary<string, double>(StringComparer.Ordinal);
}
return relevant
.GroupBy(c => c.SourceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Sum(c => c.AdjustedScore) / total, StringComparer.Ordinal);
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(SourceQuotaGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Immutable;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates;
public sealed record UnknownsBudgetGateOptions
{
public bool Enabled { get; init; } = true;
public int MaxUnknownCount { get; init; } = 5;
public double MaxCumulativeUncertainty { get; init; } = 2.0;
public bool EscalateOnFail { get; init; } = true;
}
public sealed class UnknownsBudgetGate : IPolicyGate
{
private readonly UnknownsBudgetGateOptions _options;
public UnknownsBudgetGate(UnknownsBudgetGateOptions? options = null)
{
_options = options ?? new UnknownsBudgetGateOptions();
}
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
{
if (!_options.Enabled)
{
return Task.FromResult(Pass("disabled"));
}
var unknownCount = context.UnknownCount;
var cumulative = context.UnknownClaimScores.Sum(score => 1.0 - score);
var countExceeded = unknownCount > _options.MaxUnknownCount;
var cumulativeExceeded = cumulative > _options.MaxCumulativeUncertainty;
var passed = !countExceeded && !cumulativeExceeded;
var details = ImmutableDictionary<string, object>.Empty
.Add("unknownCount", unknownCount)
.Add("maxUnknownCount", _options.MaxUnknownCount)
.Add("cumulativeUncertainty", cumulative)
.Add("maxCumulativeUncertainty", _options.MaxCumulativeUncertainty);
return Task.FromResult(new GateResult
{
GateName = nameof(UnknownsBudgetGate),
Passed = passed,
Reason = passed ? null : "unknowns_budget_exceeded",
Details = details,
});
}
private static GateResult Pass(string reason) => new()
{
GateName = nameof(UnknownsBudgetGate),
Passed = true,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty,
};
}

View File

@@ -11,6 +11,7 @@
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.2" />
</ItemGroup>

View File

@@ -7,4 +7,17 @@ This file mirrors sprint work for the `StellaOps.Policy` library.
| `DET-3401-001` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `FreshnessBucket` + `FreshnessMultiplierConfig` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/FreshnessModels.cs` and covered bucket boundaries in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/EvidenceFreshnessCalculatorTests.cs`. |
| `DET-3401-002` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Implemented `EvidenceFreshnessCalculator` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/EvidenceFreshnessCalculator.cs`. |
| `DET-3401-009` | `docs/implplan/SPRINT_3401_0001_0001_determinism_scoring_foundations.md` | DONE (2025-12-14) | Added `ScoreExplanation` + `ScoreExplainBuilder` in `src/Policy/__Libraries/StellaOps.Policy/Scoring/ScoreExplanation.cs` and tests in `src/Policy/__Tests/StellaOps.Policy.Tests/Scoring/ScoreExplainBuilderTests.cs`. |
| `EXC-3900-0003-0002-T1` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Defined RecheckPolicy model in `src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/RecheckPolicy.cs`. |
| `EXC-3900-0003-0002-T2` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Extended ExceptionObject, repository mapping, and migration for recheck policy tracking. |
| `EXC-3900-0003-0002-T3` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added evidence hook and requirements models in `src/Policy/__Libraries/StellaOps.Policy.Exceptions/Models/EvidenceHook.cs`. |
| `EXC-3900-0003-0002-T4` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added RecheckEvaluationService and context model. |
| `EXC-3900-0003-0002-T5` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Added EvidenceRequirementValidator and support interfaces. |
| `EXC-3900-0003-0002-T8` | `docs/implplan/SPRINT_3900_0003_0002_recheck_policy_evidence_hooks.md` | DONE (2025-12-22) | Aligned recheck/evidence migration and added Postgres tests for recheck fields. |
| `SPRINT-7000-0002-0001-T1` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added unified confidence score models in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Models/ConfidenceScore.cs`. |
| `SPRINT-7000-0002-0001-T2` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added configurable weights in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Configuration/ConfidenceWeightOptions.cs`. |
| `SPRINT-7000-0002-0001-T3` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Implemented calculator and inputs in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Services/ConfidenceCalculator.cs`. |
| `SPRINT-7000-0002-0001-T4` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added confidence evidence models in `src/Policy/__Libraries/StellaOps.Policy/Confidence/Models/ConfidenceEvidence.cs`. |
| `SPRINT-7000-0002-0001-T5` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Integrated confidence scoring into policy evaluation and runtime responses. |
| `SPRINT-7000-0002-0001-T6` | `docs/implplan/SPRINT_7000_0002_0001_unified_confidence_model.md` | DONE (2025-12-22) | Added confidence calculator tests in `src/Policy/__Tests/StellaOps.Policy.Tests/Confidence/ConfidenceCalculatorTests.cs` and runtime eval assertion. |
| `SPRINT-7100-0002-0001` | `docs/implplan/SPRINT_7100_0002_0001_policy_gates_merge.md` | DOING | Implementing ClaimScore merge + policy gates for trust lattice decisioning. |

View File

@@ -0,0 +1,168 @@
using System.Collections.Immutable;
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
namespace StellaOps.Policy.TrustLattice;
public sealed record VexClaim
{
public required string SourceId { get; init; }
public required VexStatus Status { get; init; }
public required int ScopeSpecificity { get; init; }
public required DateTimeOffset IssuedAt { get; init; }
public string? StatementDigest { get; init; }
public string? Reason { get; init; }
}
public sealed record ClaimScoreResult
{
public required double Score { get; init; }
public required double BaseTrust { get; init; }
public required double StrengthMultiplier { get; init; }
public required double FreshnessMultiplier { get; init; }
}
public sealed record MergePolicy
{
public double ConflictPenalty { get; init; } = 0.25;
public bool PreferSpecificity { get; init; } = true;
public bool RequireReplayProofOnConflict { get; init; } = true;
}
public sealed record MergeResult
{
public required VexStatus Status { get; init; }
public required double Confidence { get; init; }
public required bool HasConflicts { get; init; }
public required ImmutableArray<ScoredClaim> AllClaims { get; init; }
public required ScoredClaim WinningClaim { get; init; }
public required ImmutableArray<ConflictRecord> Conflicts { get; init; }
public bool RequiresReplayProof { get; init; }
}
public sealed record ScoredClaim
{
public required string SourceId { get; init; }
public required VexStatus Status { get; init; }
public required double OriginalScore { get; init; }
public required double AdjustedScore { get; init; }
public required int ScopeSpecificity { get; init; }
public required bool Accepted { get; init; }
public required string Reason { get; init; }
}
public sealed record ConflictRecord
{
public required string SourceId { get; init; }
public required VexStatus Status { get; init; }
public required string ConflictsWithSourceId { get; init; }
public required string Reason { get; init; }
}
public interface IClaimScoreMerger
{
MergeResult Merge(
IEnumerable<(VexClaim Claim, ClaimScoreResult Score)> scoredClaims,
MergePolicy policy,
CancellationToken ct = default);
}
public sealed class ClaimScoreMerger : IClaimScoreMerger
{
public MergeResult Merge(
IEnumerable<(VexClaim Claim, ClaimScoreResult Score)> scoredClaims,
MergePolicy policy,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(scoredClaims);
ArgumentNullException.ThrowIfNull(policy);
var input = scoredClaims.Select((pair, index) => new ClaimCandidate(pair.Claim, pair.Score, index)).ToList();
if (input.Count == 0)
{
var empty = new ScoredClaim
{
SourceId = "none",
Status = VexStatus.UnderInvestigation,
OriginalScore = 0,
AdjustedScore = 0,
ScopeSpecificity = 0,
Accepted = false,
Reason = "no_claims",
};
return new MergeResult
{
Status = VexStatus.UnderInvestigation,
Confidence = 0,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = empty,
Conflicts = ImmutableArray<ConflictRecord>.Empty,
RequiresReplayProof = false,
};
}
var scored = input
.Select(candidate => new ScoredClaim
{
SourceId = candidate.Claim.SourceId,
Status = candidate.Claim.Status,
OriginalScore = candidate.Score.Score,
AdjustedScore = candidate.Score.Score,
ScopeSpecificity = candidate.Claim.ScopeSpecificity,
Accepted = false,
Reason = "initial",
})
.ToList();
var hasConflicts = scored.Select(s => s.Status).Distinct().Count() > 1;
if (hasConflicts)
{
var penalizer = new ConflictPenalizer { ConflictPenalty = policy.ConflictPenalty };
scored = penalizer.ApplyPenalties(scored).ToList();
}
var ordered = scored
.Select((claim, index) => new { claim, index })
.OrderByDescending(x => x.claim.AdjustedScore)
.ThenByDescending(x => policy.PreferSpecificity ? x.claim.ScopeSpecificity : 0)
.ThenByDescending(x => x.claim.OriginalScore)
.ThenBy(x => x.claim.SourceId, StringComparer.Ordinal)
.ThenBy(x => x.index)
.ToList();
var winning = ordered.First().claim;
var updatedClaims = ordered.Select(x => x.claim with
{
Accepted = x.claim.SourceId == winning.SourceId && x.claim.Status == winning.Status,
Reason = x.claim.SourceId == winning.SourceId && x.claim.Status == winning.Status ? "winner" : x.claim.Reason,
}).ToImmutableArray();
var conflicts = hasConflicts
? updatedClaims
.Where(c => c.Status != winning.Status)
.OrderBy(c => c.SourceId, StringComparer.Ordinal)
.Select(c => new ConflictRecord
{
SourceId = c.SourceId,
Status = c.Status,
ConflictsWithSourceId = winning.SourceId,
Reason = "status_conflict",
})
.ToImmutableArray()
: ImmutableArray<ConflictRecord>.Empty;
return new MergeResult
{
Status = winning.Status,
Confidence = Math.Clamp(winning.AdjustedScore, 0, 1),
HasConflicts = hasConflicts,
AllClaims = updatedClaims,
WinningClaim = winning,
Conflicts = conflicts,
RequiresReplayProof = hasConflicts && policy.RequireReplayProofOnConflict,
};
}
private sealed record ClaimCandidate(VexClaim Claim, ClaimScoreResult Score, int Index);
}

View File

@@ -0,0 +1,37 @@
namespace StellaOps.Policy.TrustLattice;
public sealed class ConflictPenalizer
{
public double ConflictPenalty { get; init; } = 0.25;
public IReadOnlyList<ScoredClaim> ApplyPenalties(IReadOnlyList<ScoredClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var statuses = claims.Select(c => c.Status).Distinct().ToList();
if (statuses.Count <= 1)
{
return claims;
}
var strongest = claims
.OrderByDescending(c => c.OriginalScore)
.ThenByDescending(c => c.ScopeSpecificity)
.ThenBy(c => c.SourceId, StringComparer.Ordinal)
.First();
return claims.Select(c =>
{
if (c.Status == strongest.Status)
{
return c;
}
return c with
{
AdjustedScore = c.OriginalScore * (1 - ConflictPenalty),
Reason = $"Conflict penalty applied (disagrees with {strongest.SourceId})",
};
}).ToList();
}
}

View File

@@ -178,6 +178,18 @@ public sealed class TrustLatticeEngine
return _selector.Select(state);
}
/// <summary>
/// Merges scored VEX claims using the ClaimScore-based lattice merge algorithm.
/// </summary>
public MergeResult MergeClaims(
IEnumerable<(VexClaim Claim, ClaimScoreResult Score)> scoredClaims,
MergePolicy? policy = null,
CancellationToken ct = default)
{
var merger = new ClaimScoreMerger();
return merger.Merge(scoredClaims, policy ?? new MergePolicy(), ct);
}
/// <summary>
/// Evaluates all subjects and produces dispositions.
/// </summary>