using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace StellaOps.Provcache; /// /// Builder for constructing from evaluation results. /// Ensures deterministic digest computation for cache consistency. /// public sealed class DecisionDigestBuilder { private string? _veriKey; private string? _verdictHash; private string? _proofRoot; private ReplaySeed? _replaySeed; private DateTimeOffset? _createdAt; private DateTimeOffset? _expiresAt; private int? _trustScore; private TrustScoreBreakdown? _trustScoreBreakdown; private readonly ProvcacheOptions _options; private readonly TimeProvider _timeProvider; /// /// Creates a new DecisionDigestBuilder with default options. /// public DecisionDigestBuilder() : this(new ProvcacheOptions(), TimeProvider.System) { } /// /// Creates a new DecisionDigestBuilder with the specified options. /// /// Provcache configuration options. /// Time provider for timestamps. public DecisionDigestBuilder(ProvcacheOptions options, TimeProvider? timeProvider = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? TimeProvider.System; } /// /// Sets the VeriKey for this digest. /// public DecisionDigestBuilder WithVeriKey(string veriKey) { _veriKey = veriKey ?? throw new ArgumentNullException(nameof(veriKey)); return this; } /// /// Sets the VeriKey from a builder. /// public DecisionDigestBuilder WithVeriKey(VeriKeyBuilder builder) { ArgumentNullException.ThrowIfNull(builder); _veriKey = builder.Build(); return this; } /// /// Sets the verdict hash directly. /// public DecisionDigestBuilder WithVerdictHash(string verdictHash) { _verdictHash = verdictHash ?? throw new ArgumentNullException(nameof(verdictHash)); return this; } /// /// Computes verdict hash from sorted dispositions. /// Dispositions are sorted by key for deterministic hashing. /// /// Dictionary of finding ID to disposition. public DecisionDigestBuilder WithDispositions(IReadOnlyDictionary dispositions) { ArgumentNullException.ThrowIfNull(dispositions); // Sort by key for deterministic hash var sorted = dispositions .OrderBy(kvp => kvp.Key, StringComparer.Ordinal) .ToList(); if (sorted.Count == 0) { _verdictHash = ComputeHash(Encoding.UTF8.GetBytes("empty-verdict")); return this; } // Build deterministic string: key1=value1|key2=value2|... var sb = new StringBuilder(); foreach (var (key, value) in sorted) { if (sb.Length > 0) sb.Append('|'); sb.Append(key); sb.Append('='); sb.Append(value); } _verdictHash = ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); return this; } /// /// Sets the proof root (Merkle root of evidence) directly. /// public DecisionDigestBuilder WithProofRoot(string proofRoot) { _proofRoot = proofRoot ?? throw new ArgumentNullException(nameof(proofRoot)); return this; } /// /// Computes proof root from a list of evidence chunk hashes. /// Builds a simple binary Merkle tree for verification. /// /// Ordered list of evidence chunk hashes. public DecisionDigestBuilder WithEvidenceChunks(IReadOnlyList evidenceChunkHashes) { ArgumentNullException.ThrowIfNull(evidenceChunkHashes); if (evidenceChunkHashes.Count == 0) { _proofRoot = ComputeHash(Encoding.UTF8.GetBytes("empty-proof")); return this; } // Simple Merkle tree: recursively pair and hash until single root var currentLevel = evidenceChunkHashes .Select(h => Convert.FromHexString(StripPrefix(h))) .ToList(); while (currentLevel.Count > 1) { var nextLevel = new List(); for (int i = 0; i < currentLevel.Count; i += 2) { if (i + 1 < currentLevel.Count) { // Hash pair var combined = new byte[currentLevel[i].Length + currentLevel[i + 1].Length]; currentLevel[i].CopyTo(combined, 0); currentLevel[i + 1].CopyTo(combined, currentLevel[i].Length); nextLevel.Add(SHA256.HashData(combined)); } else { // Odd element: promote to next level nextLevel.Add(currentLevel[i]); } } currentLevel = nextLevel; } _proofRoot = $"sha256:{Convert.ToHexStringLower(currentLevel[0])}"; return this; } /// /// Sets the replay seed directly. /// public DecisionDigestBuilder WithReplaySeed(ReplaySeed replaySeed) { _replaySeed = replaySeed ?? throw new ArgumentNullException(nameof(replaySeed)); return this; } /// /// Builds replay seed from feed and rule identifiers. /// public DecisionDigestBuilder WithReplaySeed( IEnumerable feedIds, IEnumerable ruleIds, DateTimeOffset? frozenEpoch = null) { _replaySeed = new ReplaySeed { FeedIds = feedIds?.ToList() ?? [], RuleIds = ruleIds?.ToList() ?? [], FrozenEpoch = frozenEpoch }; return this; } /// /// Sets explicit timestamps for created and expires. /// public DecisionDigestBuilder WithTimestamps(DateTimeOffset createdAt, DateTimeOffset expiresAt) { _createdAt = createdAt; _expiresAt = expiresAt; return this; } /// /// Sets timestamps using the default TTL from options. /// public DecisionDigestBuilder WithDefaultTimestamps() { _createdAt = _timeProvider.GetUtcNow(); _expiresAt = _createdAt.Value.Add(_options.DefaultTtl); return this; } /// /// Sets the trust score directly. /// /// Trust score (0-100). public DecisionDigestBuilder WithTrustScore(int trustScore) { if (trustScore < 0 || trustScore > 100) throw new ArgumentOutOfRangeException(nameof(trustScore), "Trust score must be between 0 and 100."); _trustScore = trustScore; return this; } /// /// Sets the trust score from a breakdown, computing the total automatically. /// /// The trust score breakdown with component scores. public DecisionDigestBuilder WithTrustScoreBreakdown(TrustScoreBreakdown breakdown) { ArgumentNullException.ThrowIfNull(breakdown); _trustScoreBreakdown = breakdown; _trustScore = breakdown.ComputeTotal(); return this; } /// /// Computes trust score from component scores using weighted formula, /// and stores the breakdown for API responses. /// /// Reachability analysis coverage (0-100). /// SBOM completeness (0-100). /// VEX statement coverage (0-100). /// Policy freshness (0-100). /// Signer trust level (0-100). public DecisionDigestBuilder WithTrustScore( int reachabilityScore, int sbomCompletenessScore, int vexCoverageScore, int policyFreshnessScore, int signerTrustScore) { // Create breakdown with standard weights _trustScoreBreakdown = TrustScoreBreakdown.CreateDefault( reachabilityScore, sbomCompletenessScore, vexCoverageScore, policyFreshnessScore, signerTrustScore); // Compute total from breakdown _trustScore = _trustScoreBreakdown.ComputeTotal(); // Clamp to valid range _trustScore = Math.Clamp(_trustScore.Value, 0, 100); return this; } /// /// Builds the final DecisionDigest. /// /// The constructed DecisionDigest. /// If required components are missing. public DecisionDigest Build() { ValidateRequiredComponents(); return new DecisionDigest { DigestVersion = _options.DigestVersion, VeriKey = _veriKey!, VerdictHash = _verdictHash!, ProofRoot = _proofRoot!, ReplaySeed = _replaySeed!, CreatedAt = _createdAt!.Value, ExpiresAt = _expiresAt!.Value, TrustScore = _trustScore!.Value, TrustScoreBreakdown = _trustScoreBreakdown }; } /// /// Resets the builder to its initial state. /// public DecisionDigestBuilder Reset() { _veriKey = null; _verdictHash = null; _proofRoot = null; _replaySeed = null; _createdAt = null; _expiresAt = null; _trustScore = null; _trustScoreBreakdown = null; return this; } private void ValidateRequiredComponents() { var missing = new List(); if (string.IsNullOrWhiteSpace(_veriKey)) missing.Add("VeriKey"); if (string.IsNullOrWhiteSpace(_verdictHash)) missing.Add("VerdictHash"); if (string.IsNullOrWhiteSpace(_proofRoot)) missing.Add("ProofRoot"); if (_replaySeed is null) missing.Add("ReplaySeed"); if (!_createdAt.HasValue) missing.Add("CreatedAt"); if (!_expiresAt.HasValue) missing.Add("ExpiresAt"); if (!_trustScore.HasValue) missing.Add("TrustScore"); if (missing.Count > 0) { throw new InvalidOperationException( $"Cannot build DecisionDigest: missing required components: {string.Join(", ", missing)}. " + "Use the With* methods to set all required components before calling Build()."); } } private static string ComputeHash(ReadOnlySpan data) { Span hash = stackalloc byte[32]; SHA256.HashData(data, hash); return $"sha256:{Convert.ToHexStringLower(hash)}"; } private static string StripPrefix(string hash) { if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) return hash[7..]; return hash; } }