DET-004: Refactor Policy library for determinism - Gates, Snapshots, TrustLattice, Scoring, Explanation

- VexProofGate: Inject TimeProvider for proof age validation
- SnapshotBuilder: Inject TimeProvider for WithVex/WithSbom/WithReachability/Build
- CsafVexNormalizer, OpenVexNormalizer, VexNormalizers: Add optional issuedAt parameter
- TrustLatticeEngine.ClaimBuilder: Add optional issuedAt parameter to Build
- PolicyBundle: Add asOf parameter to IsTrusted and GetMaxAssurance
- ProofLedger: Add createdAtUtc parameter to ToJson
- ScoreAttestationBuilder: Add scoredAt parameter to Create
- ScoringRulesSnapshotBuilder: Add createdAt parameter to Create
- TrustSourceWeightService: Inject TimeProvider for stale data calculation
- PolicyExplanation.Create: Add evaluatedAt parameter
- PolicyExplanationRecord.FromExplanation: Add recordId and evaluatedAt parameters
- PolicyPreviewService: Inject TimeProvider for snapshot creation
- PolicySnapshotStore: Inject IGuidProvider for audit entry ID generation
This commit is contained in:
StellaOps Bot
2026-01-04 13:33:21 +02:00
parent ae78af4692
commit f5f12acbf0
14 changed files with 91 additions and 35 deletions

View File

@@ -104,6 +104,7 @@ public sealed record VexProofGateContext
public sealed class VexProofGate : IPolicyGate
{
private readonly VexProofGateOptions _options;
private readonly TimeProvider _timeProvider;
// Confidence tier ordering for comparison
private static readonly IReadOnlyDictionary<string, int> ConfidenceTierOrder =
@@ -114,9 +115,10 @@ public sealed class VexProofGate : IPolicyGate
["high"] = 3,
};
public VexProofGate(VexProofGateOptions? options = null)
public VexProofGate(VexProofGateOptions? options = null, TimeProvider? timeProvider = null)
{
_options = options ?? new VexProofGateOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<GateResult> EvaluateAsync(
@@ -207,7 +209,7 @@ public sealed class VexProofGate : IPolicyGate
// Validate proof age
if (_options.MaxProofAgeHours >= 0 && proofContext.ProofComputedAt.HasValue)
{
var proofAge = DateTimeOffset.UtcNow - proofContext.ProofComputedAt.Value;
var proofAge = _timeProvider.GetUtcNow() - proofContext.ProofComputedAt.Value;
details["proofAgeHours"] = proofAge.TotalHours;
details["maxProofAgeHours"] = _options.MaxProofAgeHours;

View File

@@ -61,6 +61,16 @@ public sealed record PolicyExplanation(
/// <summary>
/// Creates an explanation with full context for persistence.
/// </summary>
/// <param name="findingId">The finding ID.</param>
/// <param name="decision">The policy decision.</param>
/// <param name="ruleName">The rule name.</param>
/// <param name="reason">The reason for the decision.</param>
/// <param name="nodes">The explanation nodes.</param>
/// <param name="ruleHits">Optional rule hits.</param>
/// <param name="inputs">Optional evaluated inputs.</param>
/// <param name="policyVersion">Optional policy version.</param>
/// <param name="correlationId">Optional correlation ID.</param>
/// <param name="evaluatedAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
public static PolicyExplanation Create(
string findingId,
PolicyVerdictStatus decision,
@@ -70,12 +80,13 @@ public sealed record PolicyExplanation(
IEnumerable<RuleHit>? ruleHits = null,
IDictionary<string, object?>? inputs = null,
string? policyVersion = null,
string? correlationId = null) =>
string? correlationId = null,
DateTimeOffset? evaluatedAt = null) =>
new(findingId, decision, ruleName, reason, nodes.ToImmutableArray())
{
RuleHits = ruleHits?.ToImmutableArray() ?? ImmutableArray<RuleHit>.Empty,
EvaluatedInputs = inputs?.ToImmutableDictionary() ?? ImmutableDictionary<string, object?>.Empty,
EvaluatedAt = DateTimeOffset.UtcNow,
EvaluatedAt = evaluatedAt ?? DateTimeOffset.UtcNow,
PolicyVersion = policyVersion,
CorrelationId = correlationId
};
@@ -214,13 +225,21 @@ public sealed record PolicyExplanationRecord(
/// <summary>
/// Creates a persistence record from an explanation.
/// </summary>
/// <param name="explanation">The explanation to convert.</param>
/// <param name="policyId">The policy ID.</param>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <param name="actor">Optional actor who triggered the evaluation.</param>
/// <param name="recordId">Optional record ID for deterministic testing. If null, generates a new GUID.</param>
/// <param name="evaluatedAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
public static PolicyExplanationRecord FromExplanation(
PolicyExplanation explanation,
string policyId,
string? tenantId = null,
string? actor = null)
string? actor = null,
string? recordId = null,
DateTimeOffset? evaluatedAt = null)
{
var id = $"pexp-{Guid.NewGuid():N}";
var id = recordId ?? $"pexp-{Guid.NewGuid():N}";
var ruleHitsJson = System.Text.Json.JsonSerializer.Serialize(explanation.RuleHits);
var inputsJson = System.Text.Json.JsonSerializer.Serialize(explanation.EvaluatedInputs);
var treeJson = System.Text.Json.JsonSerializer.Serialize(explanation.Nodes);
@@ -235,7 +254,7 @@ public sealed record PolicyExplanationRecord(
RuleHitsJson: ruleHitsJson,
InputsJson: inputsJson,
ExplanationTreeJson: treeJson,
EvaluatedAt: explanation.EvaluatedAt ?? DateTimeOffset.UtcNow,
EvaluatedAt: explanation.EvaluatedAt ?? evaluatedAt ?? DateTimeOffset.UtcNow,
CorrelationId: explanation.CorrelationId,
TenantId: tenantId,
Actor: actor);

View File

@@ -13,11 +13,16 @@ public sealed class PolicyPreviewService
{
private readonly PolicySnapshotStore _snapshotStore;
private readonly ILogger<PolicyPreviewService> _logger;
private readonly TimeProvider _timeProvider;
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
public PolicyPreviewService(
PolicySnapshotStore snapshotStore,
ILogger<PolicyPreviewService> logger,
TimeProvider? timeProvider = null)
{
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
@@ -59,7 +64,7 @@ public sealed class PolicyPreviewService
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
request.SnapshotOverride?.RevisionId ?? "preview",
digest,
DateTimeOffset.UtcNow,
_timeProvider.GetUtcNow(),
request.ProposedPolicy.Actor,
request.ProposedPolicy.Format,
binding.Document,

View File

@@ -2,6 +2,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
namespace StellaOps.Policy;
@@ -10,6 +11,7 @@ public sealed class PolicySnapshotStore
private readonly IPolicySnapshotRepository _snapshotRepository;
private readonly IPolicyAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<PolicySnapshotStore> _logger;
private readonly SemaphoreSlim _mutex = new(1, 1);
@@ -17,11 +19,13 @@ public sealed class PolicySnapshotStore
IPolicySnapshotRepository snapshotRepository,
IPolicyAuditRepository auditRepository,
TimeProvider? timeProvider,
IGuidProvider? guidProvider,
ILogger<PolicySnapshotStore> logger)
{
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -72,7 +76,7 @@ public sealed class PolicySnapshotStore
var auditMessage = content.Description ?? "Policy snapshot created";
var auditEntry = new PolicyAuditEntry(
Guid.NewGuid(),
_guidProvider.NewGuid(),
createdAt,
"snapshot.created",
revisionId,

View File

@@ -118,15 +118,16 @@ public sealed class ProofLedger
/// Serialize the ledger to JSON.
/// </summary>
/// <param name="options">Optional JSON serializer options.</param>
/// <param name="createdAtUtc">Optional timestamp for deterministic testing. If null, uses current time.</param>
/// <returns>The JSON representation of the ledger.</returns>
public string ToJson(JsonSerializerOptions? options = null)
public string ToJson(JsonSerializerOptions? options = null, DateTimeOffset? createdAtUtc = null)
{
lock (_lock)
{
var payload = new ProofLedgerPayload(
Nodes: [.. _nodes],
RootHash: RootHash(),
CreatedAtUtc: DateTimeOffset.UtcNow);
CreatedAtUtc: createdAtUtc ?? DateTimeOffset.UtcNow);
return JsonSerializer.Serialize(payload, options ?? DefaultJsonOptions);
}

View File

@@ -320,17 +320,25 @@ public sealed class ScoreAttestationBuilder
/// <summary>
/// Creates a new builder.
/// </summary>
/// <param name="subjectDigest">The subject digest.</param>
/// <param name="overallScore">The overall score.</param>
/// <param name="confidence">The confidence value.</param>
/// <param name="breakdown">The score breakdown.</param>
/// <param name="policy">The scoring policy reference.</param>
/// <param name="inputs">The scoring inputs.</param>
/// <param name="scoredAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
public static ScoreAttestationBuilder Create(
string subjectDigest,
int overallScore,
double confidence,
ScoreBreakdown breakdown,
ScoringPolicyRef policy,
ScoringInputs inputs)
ScoringInputs inputs,
DateTimeOffset? scoredAt = null)
{
return new ScoreAttestationBuilder(new ScoreAttestationStatement
{
ScoredAt = DateTimeOffset.UtcNow,
ScoredAt = scoredAt ?? DateTimeOffset.UtcNow,
SubjectDigest = subjectDigest,
OverallScore = overallScore,
Confidence = confidence,

View File

@@ -346,13 +346,16 @@ public sealed class ScoringRulesSnapshotBuilder
/// <summary>
/// Creates a new builder with defaults.
/// </summary>
public static ScoringRulesSnapshotBuilder Create(string id, int version)
/// <param name="id">The snapshot ID.</param>
/// <param name="version">The snapshot version.</param>
/// <param name="createdAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
public static ScoringRulesSnapshotBuilder Create(string id, int version, DateTimeOffset? createdAt = null)
{
return new ScoringRulesSnapshotBuilder(new ScoringRulesSnapshot
{
Id = id,
Version = version,
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
Digest = "", // Will be computed on build
Weights = new ScoringWeights(),
Thresholds = new GradeThresholds(),

View File

@@ -281,10 +281,12 @@ public sealed record WeightedMergeResult
public sealed class TrustSourceWeightService
{
private readonly TrustSourceWeightConfig _config;
private readonly TimeProvider _timeProvider;
public TrustSourceWeightService(TrustSourceWeightConfig? config = null)
public TrustSourceWeightService(TrustSourceWeightConfig? config = null, TimeProvider? timeProvider = null)
{
_config = config ?? new TrustSourceWeightConfig();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -320,7 +322,7 @@ public sealed class TrustSourceWeightService
// Penalty for stale data (>7 days old)
if (source.FetchedAt.HasValue)
{
var age = DateTimeOffset.UtcNow - source.FetchedAt.Value;
var age = _timeProvider.GetUtcNow() - source.FetchedAt.Value;
if (age.TotalDays > 7)
{
weight *= 0.95;

View File

@@ -18,10 +18,12 @@ public sealed class SnapshotBuilder
private TrustBundleRef? _trust;
private DeterminismProfile? _environment;
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
public SnapshotBuilder(ICryptoHash cryptoHash)
public SnapshotBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public SnapshotBuilder WithEngine(string name, string version, string commit)
@@ -81,7 +83,7 @@ public sealed class SnapshotBuilder
{
Name = name,
Type = KnowledgeSourceTypes.Vex,
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
Epoch = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture),
Digest = digest,
Origin = origin
});
@@ -94,7 +96,7 @@ public sealed class SnapshotBuilder
{
Name = name,
Type = KnowledgeSourceTypes.Sbom,
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
Epoch = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture),
Digest = digest,
Origin = origin
});
@@ -107,7 +109,7 @@ public sealed class SnapshotBuilder
{
Name = name,
Type = KnowledgeSourceTypes.Reachability,
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
Epoch = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture),
Digest = digest,
Origin = origin
});
@@ -148,7 +150,7 @@ public sealed class SnapshotBuilder
var manifest = new KnowledgeSnapshotManifest
{
SnapshotId = "", // Placeholder
CreatedAt = DateTimeOffset.UtcNow,
CreatedAt = _timeProvider.GetUtcNow(),
Engine = _engine,
Plugins = _plugins.ToList(),
Policy = _policy,

View File

@@ -186,7 +186,8 @@ public sealed class CsafVexNormalizer : IVexNormalizer
CsafFlagLabel flag = CsafFlagLabel.None,
string? remediation = null,
Principal? principal = null,
TrustLabel? trustLabel = null)
TrustLabel? trustLabel = null,
DateTimeOffset? issuedAt = null)
{
var assertions = new List<AtomAssertion>();
@@ -220,7 +221,7 @@ public sealed class CsafVexNormalizer : IVexNormalizer
Issuer = principal ?? Principal.Unknown,
Assertions = assertions,
TrustLabel = trustLabel,
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
};
}
}

View File

@@ -149,7 +149,8 @@ public sealed class OpenVexNormalizer : IVexNormalizer
string? actionStatement = null,
string? impactStatement = null,
Principal? principal = null,
TrustLabel? trustLabel = null)
TrustLabel? trustLabel = null,
DateTimeOffset? issuedAt = null)
{
var assertions = new List<AtomAssertion>();
@@ -191,7 +192,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
Issuer = principal ?? Principal.Unknown,
Assertions = assertions,
TrustLabel = trustLabel,
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
};
}
}

View File

@@ -235,9 +235,12 @@ public sealed record PolicyBundle
/// <summary>
/// Checks if a principal is trusted for a given scope.
/// </summary>
public bool IsTrusted(Principal principal, AuthorityScope? requiredScope = null)
/// <param name="principal">The principal to check.</param>
/// <param name="requiredScope">Optional required authority scope.</param>
/// <param name="asOf">Optional timestamp for deterministic testing. If null, uses current time.</param>
public bool IsTrusted(Principal principal, AuthorityScope? requiredScope = null, DateTimeOffset? asOf = null)
{
var now = DateTimeOffset.UtcNow;
var now = asOf ?? DateTimeOffset.UtcNow;
foreach (var root in TrustRoots)
{
@@ -257,9 +260,11 @@ public sealed record PolicyBundle
/// <summary>
/// Gets the maximum assurance level for a principal.
/// </summary>
public AssuranceLevel? GetMaxAssurance(Principal principal)
/// <param name="principal">The principal to check.</param>
/// <param name="asOf">Optional timestamp for deterministic testing. If null, uses current time.</param>
public AssuranceLevel? GetMaxAssurance(Principal principal, DateTimeOffset? asOf = null)
{
var now = DateTimeOffset.UtcNow;
var now = asOf ?? DateTimeOffset.UtcNow;
foreach (var root in TrustRoots)
{

View File

@@ -397,7 +397,8 @@ public sealed class TrustLatticeEngine
/// <summary>
/// Builds and ingests the claim.
/// </summary>
public Claim Build()
/// <param name="issuedAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
public Claim Build(DateTimeOffset? issuedAt = null)
{
if (_subject is null)
throw new InvalidOperationException("Subject is required.");
@@ -409,7 +410,7 @@ public sealed class TrustLatticeEngine
TrustLabel = _trustLabel,
Assertions = _assertions,
EvidenceRefs = _evidenceRefs,
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
};
return _engine.IngestClaim(claim);

View File

@@ -270,6 +270,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
/// <param name="detail">Optional detail text.</param>
/// <param name="principal">The principal making the assertion.</param>
/// <param name="trustLabel">Optional trust label.</param>
/// <param name="issuedAt">Optional timestamp for deterministic testing.</param>
/// <returns>A normalized claim.</returns>
public Claim NormalizeStatement(
Subject subject,
@@ -277,7 +278,8 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
CycloneDxJustification justification = CycloneDxJustification.None,
string? detail = null,
Principal? principal = null,
TrustLabel? trustLabel = null)
TrustLabel? trustLabel = null,
DateTimeOffset? issuedAt = null)
{
var assertions = new List<AtomAssertion>();
@@ -312,7 +314,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
Issuer = principal ?? Principal.Unknown,
Assertions = assertions,
TrustLabel = trustLabel,
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
};
}
}