using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Attestation;
///
/// Predicate for DSSE-wrapped policy verdict attestations.
/// URI: https://stellaops.dev/predicates/policy-verdict@v1
///
public sealed record VerdictPredicate
{
public const string PredicateType = "https://stellaops.dev/predicates/policy-verdict@v1";
public VerdictPredicate(
string tenantId,
string policyId,
int policyVersion,
string runId,
string findingId,
DateTimeOffset evaluatedAt,
VerdictInfo verdict,
IEnumerable? ruleChain = null,
IEnumerable? evidence = null,
IEnumerable? vexImpacts = null,
VerdictReachability? reachability = null,
ImmutableSortedDictionary? metadata = null)
{
Type = PredicateType;
TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId));
PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId));
if (policyVersion <= 0)
{
throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive.");
}
PolicyVersion = policyVersion;
RunId = Validation.EnsureId(runId, nameof(runId));
FindingId = Validation.EnsureSimpleIdentifier(findingId, nameof(findingId));
EvaluatedAt = Validation.NormalizeTimestamp(evaluatedAt);
Verdict = verdict ?? throw new ArgumentNullException(nameof(verdict));
RuleChain = NormalizeRuleChain(ruleChain);
Evidence = NormalizeEvidence(evidence);
VexImpacts = NormalizeVexImpacts(vexImpacts);
Reachability = reachability;
Metadata = NormalizeMetadata(metadata);
}
[JsonPropertyName("_type")]
public string Type { get; }
public string TenantId { get; }
public string PolicyId { get; }
public int PolicyVersion { get; }
public string RunId { get; }
public string FindingId { get; }
public DateTimeOffset EvaluatedAt { get; }
public VerdictInfo Verdict { get; }
public ImmutableArray RuleChain { get; }
public ImmutableArray Evidence { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray VexImpacts { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public VerdictReachability? Reachability { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary Metadata { get; }
private static ImmutableArray NormalizeRuleChain(IEnumerable? rules)
{
if (rules is null)
{
return ImmutableArray.Empty;
}
return rules
.Where(static rule => rule is not null)
.Select(static rule => rule!)
.ToImmutableArray();
}
private static ImmutableArray NormalizeEvidence(IEnumerable? evidence)
{
if (evidence is null)
{
return ImmutableArray.Empty;
}
return evidence
.Where(static item => item is not null)
.Select(static item => item!)
.OrderBy(static item => item.Type, StringComparer.Ordinal)
.ThenBy(static item => item.Reference, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray NormalizeVexImpacts(IEnumerable? impacts)
{
if (impacts is null)
{
return ImmutableArray.Empty;
}
return impacts
.Where(static impact => impact is not null)
.Select(static impact => impact!)
.OrderBy(static impact => impact.StatementId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableSortedDictionary NormalizeMetadata(ImmutableSortedDictionary? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableSortedDictionary.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal);
foreach (var entry in metadata)
{
var key = Validation.TrimToNull(entry.Key)?.ToLowerInvariant();
var value = Validation.TrimToNull(entry.Value);
if (key is not null && value is not null)
{
builder[key] = value;
}
}
return builder.ToImmutable();
}
}
///
/// Verdict information (status, severity, score, rationale).
///
public sealed record VerdictInfo
{
public VerdictInfo(
string status,
string severity,
double score,
string? rationale = null)
{
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Severity = Validation.TrimToNull(severity) ?? throw new ArgumentNullException(nameof(severity));
Score = score < 0 || score > 100
? throw new ArgumentOutOfRangeException(nameof(score), score, "Score must be between 0 and 100.")
: score;
Rationale = Validation.TrimToNull(rationale);
}
public string Status { get; }
public string Severity { get; }
public double Score { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Rationale { get; }
}
///
/// Rule execution entry in verdict rule chain.
///
public sealed record VerdictRuleExecution
{
public VerdictRuleExecution(
string ruleId,
string action,
string decision,
double? score = null)
{
RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId));
Action = Validation.TrimToNull(action) ?? throw new ArgumentNullException(nameof(action));
Decision = Validation.TrimToNull(decision) ?? throw new ArgumentNullException(nameof(decision));
Score = score;
}
public string RuleId { get; }
public string Action { get; }
public string Decision { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Score { get; }
}
///
/// Evidence item considered during verdict evaluation.
///
public sealed record VerdictEvidence
{
public VerdictEvidence(
string type,
string reference,
string source,
string status,
string? digest = null,
double? weight = null,
ImmutableSortedDictionary? metadata = null)
{
Type = Validation.TrimToNull(type) ?? throw new ArgumentNullException(nameof(type));
Reference = Validation.TrimToNull(reference) ?? throw new ArgumentNullException(nameof(reference));
Source = Validation.TrimToNull(source) ?? throw new ArgumentNullException(nameof(source));
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Digest = Validation.TrimToNull(digest);
Weight = weight;
Metadata = NormalizeMetadata(metadata);
}
public string Type { get; }
public string Reference { get; }
public string Source { get; }
public string Status { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Digest { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Weight { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableSortedDictionary Metadata { get; }
private static ImmutableSortedDictionary NormalizeMetadata(ImmutableSortedDictionary? metadata)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableSortedDictionary.Empty;
}
return metadata;
}
}
///
/// VEX statement impact on verdict.
///
public sealed record VerdictVexImpact
{
public VerdictVexImpact(
string statementId,
string provider,
string status,
bool accepted,
string? justification = null)
{
StatementId = Validation.TrimToNull(statementId) ?? throw new ArgumentNullException(nameof(statementId));
Provider = Validation.TrimToNull(provider) ?? throw new ArgumentNullException(nameof(provider));
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Accepted = accepted;
Justification = Validation.TrimToNull(justification);
}
public string StatementId { get; }
public string Provider { get; }
public string Status { get; }
public bool Accepted { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Justification { get; }
}
///
/// Reachability analysis results for verdict.
///
public sealed record VerdictReachability
{
public VerdictReachability(
string status,
IEnumerable? paths = null)
{
Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status));
Paths = NormalizePaths(paths);
}
public string Status { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ImmutableArray Paths { get; }
private static ImmutableArray NormalizePaths(IEnumerable? paths)
{
if (paths is null)
{
return ImmutableArray.Empty;
}
return paths
.Where(static path => path is not null)
.Select(static path => path!)
.ToImmutableArray();
}
}
///
/// Reachability path from entrypoint to sink.
///
public sealed record VerdictReachabilityPath
{
public VerdictReachabilityPath(
string entrypoint,
string sink,
string confidence,
string? digest = null)
{
Entrypoint = Validation.TrimToNull(entrypoint) ?? throw new ArgumentNullException(nameof(entrypoint));
Sink = Validation.TrimToNull(sink) ?? throw new ArgumentNullException(nameof(sink));
Confidence = Validation.TrimToNull(confidence) ?? throw new ArgumentNullException(nameof(confidence));
Digest = Validation.TrimToNull(digest);
}
public string Entrypoint { get; }
public string Sink { get; }
public string Confidence { get; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Digest { get; }
}