using System.Collections.Immutable;
namespace StellaOps.Policy;
///
/// Structured explanation describing how a policy decision was reached.
///
/// Identifier of the evaluated finding.
/// Final verdict status (e.g., Allow, Block, Warned).
/// Name of the rule that matched, if any.
/// Human-readable summary.
/// Tree of evaluated nodes (rule, match, action, penalties, quieting, unknown confidence).
public sealed record PolicyExplanation(
string FindingId,
PolicyVerdictStatus Decision,
string? RuleName,
string Reason,
ImmutableArray Nodes)
{
///
/// Detailed rule hit information for audit trails.
///
public ImmutableArray RuleHits { get; init; } = ImmutableArray.Empty;
///
/// Input signals that were evaluated during decision making.
///
public ImmutableDictionary EvaluatedInputs { get; init; } =
ImmutableDictionary.Empty;
///
/// Timestamp when this explanation was generated (UTC).
///
public DateTimeOffset? EvaluatedAt { get; init; }
///
/// Policy version that was used for evaluation.
///
public string? PolicyVersion { get; init; }
///
/// Correlation ID for tracing across systems.
///
public string? CorrelationId { get; init; }
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Pass, ruleName, reason, nodes.ToImmutableArray());
public static PolicyExplanation Block(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Blocked, ruleName, reason, nodes.ToImmutableArray());
public static PolicyExplanation Warn(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Warned, ruleName, reason, nodes.ToImmutableArray());
///
/// Creates an explanation with full context for persistence.
///
public static PolicyExplanation Create(
string findingId,
PolicyVerdictStatus decision,
string? ruleName,
string reason,
IEnumerable nodes,
IEnumerable? ruleHits = null,
IDictionary? inputs = null,
string? policyVersion = null,
string? correlationId = null) =>
new(findingId, decision, ruleName, reason, nodes.ToImmutableArray())
{
RuleHits = ruleHits?.ToImmutableArray() ?? ImmutableArray.Empty,
EvaluatedInputs = inputs?.ToImmutableDictionary() ?? ImmutableDictionary.Empty,
EvaluatedAt = DateTimeOffset.UtcNow,
PolicyVersion = policyVersion,
CorrelationId = correlationId
};
}
///
/// A single explanation node with optional children to capture evaluation breadcrumbs.
///
/// Short classifier (e.g., "rule", "match", "penalty", "quiet", "unknown").
/// Human-readable label.
/// Optional detail (numeric or string rendered as text).
/// Nested explanation nodes.
public sealed record PolicyExplanationNode(
string Kind,
string Label,
string? Detail,
ImmutableArray Children)
{
public static PolicyExplanationNode Leaf(string kind, string label, string? detail = null) =>
new(kind, label, detail, ImmutableArray.Empty);
public static PolicyExplanationNode Branch(string kind, string label, string? detail = null, params PolicyExplanationNode[] children) =>
new(kind, label, detail, children.ToImmutableArray());
///
/// Creates a rule evaluation node showing match result.
///
public static PolicyExplanationNode RuleEvaluation(string ruleId, bool matched, string? reason = null) =>
Leaf("rule_eval", $"Rule '{ruleId}' {(matched ? "matched" : "did not match")}", reason);
///
/// Creates an input signal node showing what value was evaluated.
///
public static PolicyExplanationNode InputSignal(string signalName, object? value) =>
Leaf("input", signalName, value?.ToString());
///
/// Creates a condition evaluation node.
///
public static PolicyExplanationNode ConditionEvaluation(string field, string op, object? expected, object? actual, bool satisfied) =>
Leaf("condition", $"{field} {op} {expected}", $"actual={actual}, satisfied={satisfied}");
}
///
/// Represents a rule that was evaluated and its match result.
///
/// Unique identifier of the rule.
/// Human-readable name of the rule.
/// Whether the rule matched the input.
/// The effect that would be applied if matched (allow/deny).
/// Rule priority/precedence for conflict resolution.
/// Conditions that were satisfied.
/// Conditions that were not satisfied.
public sealed record RuleHit(
string RuleId,
string? RuleName,
bool Matched,
string Effect,
int Priority,
ImmutableArray MatchedConditions,
ImmutableArray FailedConditions)
{
///
/// Creates a successful rule hit.
///
public static RuleHit Match(
string ruleId,
string? ruleName,
string effect,
int priority = 0,
IEnumerable? matchedConditions = null) =>
new(ruleId, ruleName, true, effect, priority,
matchedConditions?.ToImmutableArray() ?? ImmutableArray.Empty,
ImmutableArray.Empty);
///
/// Creates a rule miss (no match).
///
public static RuleHit Miss(
string ruleId,
string? ruleName,
string effect,
int priority = 0,
IEnumerable? failedConditions = null) =>
new(ruleId, ruleName, false, effect, priority,
ImmutableArray.Empty,
failedConditions?.ToImmutableArray() ?? ImmutableArray.Empty);
}
///
/// Result of evaluating a single condition.
///
/// The field/path that was evaluated.
/// The comparison operator used.
/// The expected value from the rule.
/// The actual value from the input.
/// Whether the condition was satisfied.
public sealed record ConditionResult(
string Field,
string Operator,
object? ExpectedValue,
object? ActualValue,
bool Satisfied);
///
/// Persistence-ready explanation record for storage in databases or audit logs.
///
/// Unique identifier for this explanation record.
/// The finding that was evaluated.
/// The policy that was applied.
/// Version of the policy.
/// Final decision status.
/// Human-readable reason for the decision.
/// Serialized rule hits for storage.
/// Serialized evaluated inputs for storage.
/// Serialized explanation tree for storage.
/// When the evaluation occurred.
/// Trace correlation ID.
/// Optional tenant identifier.
/// Optional actor who triggered the evaluation.
public sealed record PolicyExplanationRecord(
string Id,
string FindingId,
string PolicyId,
string PolicyVersion,
string Decision,
string Reason,
string RuleHitsJson,
string InputsJson,
string ExplanationTreeJson,
DateTimeOffset EvaluatedAt,
string? CorrelationId,
string? TenantId,
string? Actor)
{
///
/// Creates a persistence record from an explanation.
///
public static PolicyExplanationRecord FromExplanation(
PolicyExplanation explanation,
string policyId,
string? tenantId = null,
string? actor = null)
{
var id = $"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);
return new PolicyExplanationRecord(
Id: id,
FindingId: explanation.FindingId,
PolicyId: policyId,
PolicyVersion: explanation.PolicyVersion ?? "unknown",
Decision: explanation.Decision.ToString(),
Reason: explanation.Reason,
RuleHitsJson: ruleHitsJson,
InputsJson: inputsJson,
ExplanationTreeJson: treeJson,
EvaluatedAt: explanation.EvaluatedAt ?? DateTimeOffset.UtcNow,
CorrelationId: explanation.CorrelationId,
TenantId: tenantId,
Actor: actor);
}
}
///
/// Store interface for persisting and retrieving policy explanations.
///
public interface IPolicyExplanationStore
{
///
/// Saves an explanation record.
///
Task SaveAsync(PolicyExplanationRecord record, CancellationToken cancellationToken = default);
///
/// Retrieves an explanation by ID.
///
Task GetByIdAsync(string id, CancellationToken cancellationToken = default);
///
/// Retrieves explanations for a finding.
///
Task> GetByFindingIdAsync(
string findingId,
int limit = 100,
CancellationToken cancellationToken = default);
///
/// Retrieves explanations by correlation ID.
///
Task> GetByCorrelationIdAsync(
string correlationId,
CancellationToken cancellationToken = default);
///
/// Queries explanations with filtering.
///
Task> QueryAsync(
PolicyExplanationQuery query,
CancellationToken cancellationToken = default);
}
///
/// Query parameters for searching explanation records.
///
/// Filter by policy ID.
/// Filter by tenant ID.
/// Filter by decision status.
/// Filter by evaluation time (minimum).
/// Filter by evaluation time (maximum).
/// Maximum number of records to return.
/// Number of records to skip.
public sealed record PolicyExplanationQuery(
string? PolicyId = null,
string? TenantId = null,
string? Decision = null,
DateTimeOffset? Since = null,
DateTimeOffset? Until = null,
int Limit = 100,
int Offset = 0);