using System.Text.Json; using System.Text.Json.Nodes; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Hashing; using StellaOps.Findings.Ledger.Infrastructure.Policy; namespace StellaOps.Findings.Ledger.Services; public static class LedgerProjectionReducer { public static ProjectionReduceResult Reduce( LedgerEventRecord record, FindingProjection? current, PolicyEvaluationResult evaluation) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(evaluation); var eventObject = record.EventBody["event"]?.AsObject() ?? throw new InvalidOperationException("Ledger event payload is missing 'event' object."); var payload = eventObject["payload"] as JsonObject; var status = evaluation.Status ?? DetermineStatus(record.EventType, payload, current?.Status); var severity = evaluation.Severity ?? DetermineSeverity(payload, current?.Severity); var riskScore = evaluation.RiskScore ?? current?.RiskScore; var riskSeverity = evaluation.RiskSeverity ?? current?.RiskSeverity; var riskProfileVersion = evaluation.RiskProfileVersion ?? current?.RiskProfileVersion; var riskExplanationId = evaluation.RiskExplanationId ?? current?.RiskExplanationId; var riskEventSequence = evaluation.RiskEventSequence ?? current?.RiskEventSequence ?? record.SequenceNumber; var labels = CloneLabels(evaluation.Labels); MergeLabels(labels, payload); var explainRef = evaluation.ExplainRef ?? DetermineExplainRef(payload, current?.ExplainRef); var rationale = CloneArray(evaluation.Rationale); if (rationale.Count == 0 && !string.IsNullOrWhiteSpace(explainRef)) { rationale.Add(explainRef); } var updatedAt = record.RecordedAt; var provisional = new FindingProjection( record.TenantId, record.FindingId, record.PolicyVersion, status, severity, riskScore, riskSeverity, riskProfileVersion, riskExplanationId, riskEventSequence, labels, record.EventId, explainRef, rationale, updatedAt, string.Empty); var cycleHash = ProjectionHashing.ComputeCycleHash(provisional); var projection = provisional with { CycleHash = cycleHash }; var historyEntry = new FindingHistoryEntry( record.TenantId, record.FindingId, record.PolicyVersion, record.EventId, projection.Status, projection.Severity, record.ActorId, DetermineComment(payload), record.OccurredAt); var actionEntry = CreateActionEntry(record, payload); return new ProjectionReduceResult(projection, historyEntry, actionEntry); } private static string DetermineStatus(string eventType, JsonObject? payload, string? currentStatus) { var candidate = ExtractString(payload, "status") ?? currentStatus; return eventType switch { LedgerEventConstants.EventFindingCreated => candidate ?? "affected", LedgerEventConstants.EventFindingStatusChanged => candidate ?? currentStatus ?? "affected", LedgerEventConstants.EventFindingClosed => candidate ?? "closed", LedgerEventConstants.EventFindingAcceptedRisk => candidate ?? "accepted_risk", _ => candidate ?? currentStatus ?? "affected" }; } private static decimal? DetermineSeverity(JsonObject? payload, decimal? current) { if (payload is null) { return current; } if (payload.TryGetPropertyValue("severity", out var severityNode)) { if (TryConvertDecimal(severityNode, out var severity)) { return severity; } if (severityNode is JsonValue value && value.TryGetValue(out string? severityString) && decimal.TryParse(severityString, out var severityFromString)) { return severityFromString; } } return current; } private static void MergeLabels(JsonObject target, JsonObject? payload) { if (payload is null) { return; } if (payload.TryGetPropertyValue("labels", out var labelsNode) && labelsNode is JsonObject labelUpdates) { foreach (var property in labelUpdates) { if (property.Value is null || property.Value.GetValueKind() == JsonValueKind.Null) { target.Remove(property.Key); } else { target[property.Key] = property.Value.DeepClone(); } } } if (payload.TryGetPropertyValue("labelsRemove", out var removeNode) && removeNode is JsonArray removeArray) { foreach (var item in removeArray) { if (item is JsonValue value && value.TryGetValue(out string? key) && !string.IsNullOrWhiteSpace(key)) { target.Remove(key); } } } } private static string? DetermineExplainRef(JsonObject? payload, string? current) { var explainRef = ExtractString(payload, "explainRef") ?? ExtractString(payload, "explain_ref"); return explainRef ?? current; } private static string? DetermineComment(JsonObject? payload) { return ExtractString(payload, "comment") ?? ExtractString(payload, "justification") ?? ExtractString(payload, "note"); } private static TriageActionEntry? CreateActionEntry(LedgerEventRecord record, JsonObject? payload) { var actionType = record.EventType switch { LedgerEventConstants.EventFindingStatusChanged => "status_change", LedgerEventConstants.EventFindingCommentAdded => "comment", LedgerEventConstants.EventFindingAssignmentChanged => "assign", LedgerEventConstants.EventFindingRemediationPlanAdded => "remediation_plan", LedgerEventConstants.EventFindingAcceptedRisk => "accept_risk", LedgerEventConstants.EventFindingAttachmentAdded => "attach_evidence", LedgerEventConstants.EventFindingClosed => "close", _ => null }; if (actionType is null) { return null; } var payloadClone = payload?.DeepClone()?.AsObject() ?? new JsonObject(); return new TriageActionEntry( record.TenantId, record.EventId, record.EventId, record.FindingId, actionType, payloadClone, record.RecordedAt, record.ActorId); } private static JsonObject CloneLabels(JsonObject? source) { return source is null ? new JsonObject() : (JsonObject)source.DeepClone(); } private static JsonArray CloneArray(JsonArray source) { ArgumentNullException.ThrowIfNull(source); var clone = new JsonArray(); foreach (var item in source) { clone.Add(item?.DeepClone()); } return clone; } private static string? ExtractString(JsonObject? obj, string propertyName) { if (obj is null) { return null; } if (!obj.TryGetPropertyValue(propertyName, out var node) || node is null) { return null; } if (node is JsonValue value && value.TryGetValue(out string? result)) { return string.IsNullOrWhiteSpace(result) ? null : result; } return node.ToString(); } private static bool TryConvertDecimal(JsonNode? node, out decimal value) { switch (node) { case null: value = default; return false; case JsonValue jsonValue when jsonValue.TryGetValue(out decimal decimalValue): value = decimalValue; return true; case JsonValue jsonValue when jsonValue.TryGetValue(out double doubleValue): value = Convert.ToDecimal(doubleValue); return true; default: if (decimal.TryParse(node.ToString(), out var parsed)) { value = parsed; return true; } value = default; return false; } } }