559 lines
23 KiB
C#
559 lines
23 KiB
C#
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
|
|
|
using System.Data.Common;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
using StellaOps.Determinism;
|
|
using StellaOps.VexLens.Consensus;
|
|
using StellaOps.VexLens.Models;
|
|
using StellaOps.VexLens.Options;
|
|
|
|
namespace StellaOps.VexLens.Storage;
|
|
|
|
/// <summary>
|
|
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
|
|
/// This proxy implementation lives in the core VexLens project to enable DI registration.
|
|
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-022)
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// For full-featured implementation with advanced queries, use PostgresConsensusProjectionStore
|
|
/// from StellaOps.VexLens.Persistence package.
|
|
/// </remarks>
|
|
public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjectionStore
|
|
{
|
|
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Storage.PostgresConsensusProjectionStoreProxy");
|
|
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
private readonly IConsensusEventEmitter? _eventEmitter;
|
|
private readonly ILogger<PostgresConsensusProjectionStoreProxy> _logger;
|
|
private readonly VexLensStorageOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly IGuidProvider _guidProvider;
|
|
|
|
public PostgresConsensusProjectionStoreProxy(
|
|
NpgsqlDataSource dataSource,
|
|
ILogger<PostgresConsensusProjectionStoreProxy> logger,
|
|
IConsensusEventEmitter? eventEmitter = null,
|
|
VexLensStorageOptions? options = null,
|
|
TimeProvider? timeProvider = null,
|
|
IGuidProvider? guidProvider = null)
|
|
{
|
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_eventEmitter = eventEmitter;
|
|
_options = options ?? new VexLensStorageOptions();
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
|
}
|
|
|
|
private const string Schema = "vexlens";
|
|
|
|
private const string InsertProjectionSql = $"""
|
|
INSERT INTO {Schema}.consensus_projections (
|
|
id, vulnerability_id, product_key, tenant_id, status, justification,
|
|
confidence_score, outcome, statement_count, conflict_count,
|
|
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
|
) VALUES (
|
|
@id, @vulnerability_id, @product_key, @tenant_id, @status, @justification,
|
|
@confidence_score, @outcome, @statement_count, @conflict_count,
|
|
@rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed
|
|
)
|
|
""";
|
|
|
|
private const string SelectByIdSql = $"""
|
|
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
|
confidence_score, outcome, statement_count, conflict_count,
|
|
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
|
FROM {Schema}.consensus_projections
|
|
WHERE id = @id
|
|
""";
|
|
|
|
private const string SelectLatestSql = $"""
|
|
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
|
confidence_score, outcome, statement_count, conflict_count,
|
|
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
|
FROM {Schema}.consensus_projections
|
|
WHERE vulnerability_id = @vulnerability_id
|
|
AND product_key = @product_key
|
|
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
|
ORDER BY computed_at DESC
|
|
LIMIT 1
|
|
""";
|
|
|
|
private const string SelectHistorySql = $"""
|
|
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
|
confidence_score, outcome, statement_count, conflict_count,
|
|
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
|
FROM {Schema}.consensus_projections
|
|
WHERE vulnerability_id = @vulnerability_id
|
|
AND product_key = @product_key
|
|
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
|
ORDER BY computed_at DESC
|
|
LIMIT @limit
|
|
""";
|
|
|
|
private const string PurgeSql = $"""
|
|
DELETE FROM {Schema}.consensus_projections
|
|
WHERE computed_at < @older_than
|
|
AND (@tenant_id IS NULL OR tenant_id = @tenant_id)
|
|
""";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ConsensusProjection> StoreAsync(
|
|
VexConsensusResult result,
|
|
StoreProjectionOptions options,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(result);
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
using var activity = ActivitySource.StartActivity("StoreAsync");
|
|
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
|
activity?.SetTag("productKey", result.ProductKey);
|
|
|
|
var projectionId = _guidProvider.NewGuid();
|
|
var now = _timeProvider.GetUtcNow();
|
|
|
|
// Check for previous projection to track history
|
|
ConsensusProjection? previous = null;
|
|
if (options.TrackHistory)
|
|
{
|
|
previous = await GetLatestAsync(
|
|
result.VulnerabilityId,
|
|
result.ProductKey,
|
|
options.TenantId,
|
|
cancellationToken);
|
|
}
|
|
|
|
var statusChanged = previous is not null && previous.Status != result.ConsensusStatus;
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction);
|
|
cmd.Parameters.AddWithValue("id", projectionId);
|
|
cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId);
|
|
cmd.Parameters.AddWithValue("product_key", result.ProductKey);
|
|
cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value);
|
|
cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus));
|
|
cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore);
|
|
cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome));
|
|
cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count);
|
|
cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0);
|
|
cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value);
|
|
cmd.Parameters.AddWithValue("computed_at", result.ComputedAt);
|
|
cmd.Parameters.AddWithValue("stored_at", now);
|
|
cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value);
|
|
cmd.Parameters.AddWithValue("status_changed", statusChanged);
|
|
|
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
|
|
var projection = new ConsensusProjection(
|
|
ProjectionId: projectionId.ToString(),
|
|
VulnerabilityId: result.VulnerabilityId,
|
|
ProductKey: result.ProductKey,
|
|
TenantId: options.TenantId,
|
|
Status: result.ConsensusStatus,
|
|
Justification: result.ConsensusJustification,
|
|
ConfidenceScore: result.ConfidenceScore,
|
|
Outcome: result.Outcome,
|
|
StatementCount: result.Contributions.Count,
|
|
ConflictCount: result.Conflicts?.Count ?? 0,
|
|
RationaleSummary: result.Rationale.Summary ?? string.Empty,
|
|
ComputedAt: result.ComputedAt,
|
|
StoredAt: now,
|
|
PreviousProjectionId: previous?.ProjectionId,
|
|
StatusChanged: statusChanged);
|
|
|
|
_logger.LogDebug(
|
|
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
|
|
projectionId, result.VulnerabilityId, result.ProductKey);
|
|
|
|
// Emit events
|
|
if (options.EmitEvent && _eventEmitter is not null)
|
|
{
|
|
await EmitEventsAsync(projection, previous, cancellationToken);
|
|
}
|
|
|
|
return projection;
|
|
}
|
|
catch
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ConsensusProjection?> GetAsync(
|
|
string projectionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(projectionId);
|
|
|
|
if (!Guid.TryParse(projectionId, out var id))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
using var activity = ActivitySource.StartActivity("GetAsync");
|
|
activity?.SetTag("projectionId", projectionId);
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var cmd = new NpgsqlCommand(SelectByIdSql, connection);
|
|
cmd.Parameters.AddWithValue("id", id);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
if (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
return MapProjection(reader);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ConsensusProjection?> GetLatestAsync(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
string? tenantId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
|
|
|
using var activity = ActivitySource.StartActivity("GetLatestAsync");
|
|
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
|
activity?.SetTag("productKey", productKey);
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var cmd = new NpgsqlCommand(SelectLatestSql, connection);
|
|
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
|
cmd.Parameters.AddWithValue("product_key", productKey);
|
|
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
|
|
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
if (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
return MapProjection(reader);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ProjectionListResult> ListAsync(
|
|
ProjectionQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(query);
|
|
|
|
using var activity = ActivitySource.StartActivity("ListAsync");
|
|
|
|
var (sql, countSql, parameters) = BuildListQuery(query);
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
|
|
// Get total count
|
|
await using var countCmd = new NpgsqlCommand(countSql, connection);
|
|
foreach (var (name, value) in parameters)
|
|
{
|
|
countCmd.Parameters.AddWithValue(name, value);
|
|
}
|
|
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
|
|
|
|
// Get results
|
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
|
foreach (var (name, value) in parameters)
|
|
{
|
|
cmd.Parameters.AddWithValue(name, value);
|
|
}
|
|
cmd.Parameters.AddWithValue("limit", query.Limit);
|
|
cmd.Parameters.AddWithValue("offset", query.Offset);
|
|
|
|
var projections = new List<ConsensusProjection>();
|
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
while (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
projections.Add(MapProjection(reader));
|
|
}
|
|
|
|
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
string? tenantId = null,
|
|
int? limit = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
|
|
|
using var activity = ActivitySource.StartActivity("GetHistoryAsync");
|
|
activity?.SetTag("vulnerabilityId", vulnerabilityId);
|
|
activity?.SetTag("productKey", productKey);
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var cmd = new NpgsqlCommand(SelectHistorySql, connection);
|
|
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
|
cmd.Parameters.AddWithValue("product_key", productKey);
|
|
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
|
cmd.Parameters.AddWithValue("limit", limit ?? _options.MaxHistoryEntries);
|
|
|
|
var projections = new List<ConsensusProjection>();
|
|
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
|
while (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
projections.Add(MapProjection(reader));
|
|
}
|
|
|
|
return projections;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> PurgeAsync(
|
|
DateTimeOffset olderThan,
|
|
string? tenantId = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
using var activity = ActivitySource.StartActivity("PurgeAsync");
|
|
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
|
|
|
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var cmd = new NpgsqlCommand(PurgeSql, connection);
|
|
cmd.Parameters.AddWithValue("older_than", olderThan);
|
|
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
|
|
|
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
|
|
|
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
|
|
|
|
return deleted;
|
|
}
|
|
|
|
private static ConsensusProjection MapProjection(DbDataReader reader)
|
|
{
|
|
return new ConsensusProjection(
|
|
ProjectionId: reader.GetGuid(0).ToString(),
|
|
VulnerabilityId: reader.GetString(1),
|
|
ProductKey: reader.GetString(2),
|
|
TenantId: reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
Status: ParseStatus(reader.GetString(4)),
|
|
Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)),
|
|
ConfidenceScore: reader.GetDouble(6),
|
|
Outcome: ParseOutcome(reader.GetString(7)),
|
|
StatementCount: reader.GetInt32(8),
|
|
ConflictCount: reader.GetInt32(9),
|
|
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
|
ComputedAt: reader.GetFieldValue<DateTimeOffset>(11),
|
|
StoredAt: reader.GetFieldValue<DateTimeOffset>(12),
|
|
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
|
|
StatusChanged: reader.GetBoolean(14));
|
|
}
|
|
|
|
private static string MapStatus(VexStatus status) => status switch
|
|
{
|
|
VexStatus.NotAffected => "not_affected",
|
|
VexStatus.Affected => "affected",
|
|
VexStatus.Fixed => "fixed",
|
|
VexStatus.UnderInvestigation => "under_investigation",
|
|
_ => "unknown"
|
|
};
|
|
|
|
private static VexStatus ParseStatus(string status) => status switch
|
|
{
|
|
"not_affected" => VexStatus.NotAffected,
|
|
"affected" => VexStatus.Affected,
|
|
"fixed" => VexStatus.Fixed,
|
|
"under_investigation" => VexStatus.UnderInvestigation,
|
|
_ => VexStatus.UnderInvestigation
|
|
};
|
|
|
|
private static string MapJustification(VexJustification justification) => justification switch
|
|
{
|
|
VexJustification.ComponentNotPresent => "component_not_present",
|
|
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
|
|
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
|
|
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
|
|
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
|
|
_ => "unknown"
|
|
};
|
|
|
|
private static VexJustification ParseJustification(string justification) => justification switch
|
|
{
|
|
"component_not_present" => VexJustification.ComponentNotPresent,
|
|
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
|
|
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
|
|
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
|
|
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
|
_ => VexJustification.VulnerableCodeNotPresent
|
|
};
|
|
|
|
private static string MapOutcome(ConsensusOutcome outcome) => outcome switch
|
|
{
|
|
ConsensusOutcome.Unanimous => "unanimous",
|
|
ConsensusOutcome.Majority => "majority",
|
|
ConsensusOutcome.Plurality => "plurality",
|
|
ConsensusOutcome.ConflictResolved => "conflict_resolved",
|
|
ConsensusOutcome.NoData => "no_data",
|
|
ConsensusOutcome.Indeterminate => "indeterminate",
|
|
_ => "indeterminate"
|
|
};
|
|
|
|
private static ConsensusOutcome ParseOutcome(string outcome) => outcome switch
|
|
{
|
|
"unanimous" => ConsensusOutcome.Unanimous,
|
|
"majority" => ConsensusOutcome.Majority,
|
|
"plurality" => ConsensusOutcome.Plurality,
|
|
"conflict_resolved" => ConsensusOutcome.ConflictResolved,
|
|
"no_data" => ConsensusOutcome.NoData,
|
|
"indeterminate" => ConsensusOutcome.Indeterminate,
|
|
_ => ConsensusOutcome.Indeterminate
|
|
};
|
|
|
|
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
|
|
{
|
|
var parameters = new List<(string name, object value)>();
|
|
var conditions = new List<string>();
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
|
{
|
|
conditions.Add("tenant_id = @tenant_id");
|
|
parameters.Add(("tenant_id", query.TenantId));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.VulnerabilityId))
|
|
{
|
|
conditions.Add("vulnerability_id ILIKE @vulnerability_id");
|
|
parameters.Add(("vulnerability_id", $"%{query.VulnerabilityId}%"));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.ProductKey))
|
|
{
|
|
conditions.Add("product_key ILIKE @product_key");
|
|
parameters.Add(("product_key", $"%{query.ProductKey}%"));
|
|
}
|
|
|
|
if (query.Status.HasValue)
|
|
{
|
|
conditions.Add("status = @status");
|
|
parameters.Add(("status", MapStatus(query.Status.Value)));
|
|
}
|
|
|
|
if (query.Outcome.HasValue)
|
|
{
|
|
conditions.Add("outcome = @outcome");
|
|
parameters.Add(("outcome", MapOutcome(query.Outcome.Value)));
|
|
}
|
|
|
|
if (query.MinimumConfidence.HasValue)
|
|
{
|
|
conditions.Add("confidence_score >= @min_confidence");
|
|
parameters.Add(("min_confidence", query.MinimumConfidence.Value));
|
|
}
|
|
|
|
if (query.ComputedAfter.HasValue)
|
|
{
|
|
conditions.Add("computed_at >= @computed_after");
|
|
parameters.Add(("computed_after", query.ComputedAfter.Value));
|
|
}
|
|
|
|
if (query.ComputedBefore.HasValue)
|
|
{
|
|
conditions.Add("computed_at <= @computed_before");
|
|
parameters.Add(("computed_before", query.ComputedBefore.Value));
|
|
}
|
|
|
|
if (query.StatusChanged.HasValue)
|
|
{
|
|
conditions.Add("status_changed = @status_changed");
|
|
parameters.Add(("status_changed", query.StatusChanged.Value));
|
|
}
|
|
|
|
var whereClause = conditions.Count > 0
|
|
? $"WHERE {string.Join(" AND ", conditions)}"
|
|
: string.Empty;
|
|
|
|
var orderColumn = query.SortBy switch
|
|
{
|
|
ProjectionSortField.ComputedAt => "computed_at",
|
|
ProjectionSortField.StoredAt => "stored_at",
|
|
ProjectionSortField.VulnerabilityId => "vulnerability_id",
|
|
ProjectionSortField.ProductKey => "product_key",
|
|
ProjectionSortField.ConfidenceScore => "confidence_score",
|
|
_ => "computed_at"
|
|
};
|
|
|
|
var orderDirection = query.SortDescending ? "DESC" : "ASC";
|
|
|
|
var sql = $"""
|
|
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
|
confidence_score, outcome, statement_count, conflict_count,
|
|
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
|
FROM {Schema}.consensus_projections
|
|
{whereClause}
|
|
ORDER BY {orderColumn} {orderDirection}
|
|
LIMIT @limit OFFSET @offset
|
|
""";
|
|
|
|
var countSql = $"SELECT COUNT(*) FROM {Schema}.consensus_projections {whereClause}";
|
|
|
|
return (sql, countSql, parameters);
|
|
}
|
|
|
|
private async Task EmitEventsAsync(
|
|
ConsensusProjection projection,
|
|
ConsensusProjection? previous,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (_eventEmitter is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var now = _timeProvider.GetUtcNow();
|
|
var computedEvent = new ConsensusComputedEvent(
|
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
|
ProjectionId: projection.ProjectionId,
|
|
VulnerabilityId: projection.VulnerabilityId,
|
|
ProductKey: projection.ProductKey,
|
|
TenantId: projection.TenantId,
|
|
Status: projection.Status,
|
|
Justification: projection.Justification,
|
|
ConfidenceScore: projection.ConfidenceScore,
|
|
Outcome: projection.Outcome,
|
|
StatementCount: projection.StatementCount,
|
|
ComputedAt: projection.ComputedAt,
|
|
EmittedAt: now);
|
|
|
|
await _eventEmitter.EmitConsensusComputedAsync(computedEvent, cancellationToken);
|
|
|
|
if (projection.StatusChanged && previous is not null)
|
|
{
|
|
var changedEvent = new ConsensusStatusChangedEvent(
|
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
|
ProjectionId: projection.ProjectionId,
|
|
VulnerabilityId: projection.VulnerabilityId,
|
|
ProductKey: projection.ProductKey,
|
|
TenantId: projection.TenantId,
|
|
PreviousStatus: previous.Status,
|
|
NewStatus: projection.Status,
|
|
ChangeReason: $"Consensus status changed from {previous.Status} to {projection.Status}",
|
|
ComputedAt: projection.ComputedAt,
|
|
EmittedAt: now);
|
|
|
|
await _eventEmitter.EmitStatusChangedAsync(changedEvent, cancellationToken);
|
|
}
|
|
}
|
|
}
|