Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,152 @@
-- VexLens Schema Migration 001: Consensus Projections
-- Creates the vexlens schema for VEX consensus persistence
-- Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-020)
-- Create schema
CREATE SCHEMA IF NOT EXISTS vexlens;
-- Enable extensions for trigram search if not present
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Consensus projections table
-- Stores computed VEX consensus results with full history support
CREATE TABLE IF NOT EXISTS vexlens.consensus_projections (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Core identity (forms unique compound key for latest lookup)
vulnerability_id TEXT NOT NULL,
product_key TEXT NOT NULL,
tenant_id TEXT,
-- Consensus result
status TEXT NOT NULL CHECK (status IN ('not_affected', 'affected', 'fixed', 'under_investigation')),
justification TEXT CHECK (justification IS NULL OR justification IN (
'component_not_present',
'vulnerable_code_not_present',
'vulnerable_code_not_in_execute_path',
'vulnerable_code_cannot_be_controlled_by_adversary',
'inline_mitigations_already_exist'
)),
confidence_score DOUBLE PRECISION NOT NULL CHECK (confidence_score >= 0.0 AND confidence_score <= 1.0),
outcome TEXT NOT NULL CHECK (outcome IN ('unanimous', 'majority', 'conflict', 'single_source', 'none')),
-- Aggregation metadata
statement_count INT NOT NULL DEFAULT 0,
conflict_count INT NOT NULL DEFAULT 0,
rationale_summary TEXT,
-- Timestamps (use UTC)
computed_at TIMESTAMPTZ NOT NULL,
stored_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- History tracking
previous_projection_id UUID REFERENCES vexlens.consensus_projections(id),
status_changed BOOLEAN NOT NULL DEFAULT FALSE,
-- Delta attestation reference
attestation_digest TEXT,
-- Full input hash for replay verification
input_hash TEXT
);
-- Unique constraint for latest projection lookup
-- Only one "active" projection per vulnerability-product-tenant at a time
CREATE UNIQUE INDEX IF NOT EXISTS idx_consensus_projections_latest
ON vexlens.consensus_projections (vulnerability_id, product_key, COALESCE(tenant_id, ''))
WHERE previous_projection_id IS NULL OR status_changed = TRUE;
-- Indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_consensus_projections_vuln_id
ON vexlens.consensus_projections (vulnerability_id);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_product_key
ON vexlens.consensus_projections (product_key);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_tenant_id
ON vexlens.consensus_projections (tenant_id)
WHERE tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_consensus_projections_status
ON vexlens.consensus_projections (status);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_outcome
ON vexlens.consensus_projections (outcome);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_computed_at
ON vexlens.consensus_projections (computed_at DESC);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_stored_at
ON vexlens.consensus_projections (stored_at DESC);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_confidence
ON vexlens.consensus_projections (confidence_score DESC);
CREATE INDEX IF NOT EXISTS idx_consensus_projections_status_changed
ON vexlens.consensus_projections (status_changed)
WHERE status_changed = TRUE;
-- Composite index for history queries
CREATE INDEX IF NOT EXISTS idx_consensus_projections_history
ON vexlens.consensus_projections (vulnerability_id, product_key, tenant_id, computed_at DESC);
-- Trigram index for fuzzy vulnerability ID search
CREATE INDEX IF NOT EXISTS idx_consensus_projections_vuln_id_trgm
ON vexlens.consensus_projections USING gin (vulnerability_id gin_trgm_ops);
-- Trigger to auto-update stored_at timestamp
CREATE OR REPLACE FUNCTION vexlens.update_stored_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.stored_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_consensus_projections_update_stored_at
BEFORE UPDATE ON vexlens.consensus_projections
FOR EACH ROW
EXECUTE FUNCTION vexlens.update_stored_at();
-- Consensus input statements table (tracks which VEX statements contributed)
CREATE TABLE IF NOT EXISTS vexlens.consensus_inputs (
projection_id UUID NOT NULL REFERENCES vexlens.consensus_projections(id) ON DELETE CASCADE,
statement_id TEXT NOT NULL,
source_id TEXT NOT NULL,
status TEXT NOT NULL,
confidence DOUBLE PRECISION,
weight DOUBLE PRECISION,
PRIMARY KEY (projection_id, statement_id)
);
CREATE INDEX IF NOT EXISTS idx_consensus_inputs_projection
ON vexlens.consensus_inputs (projection_id);
-- Consensus conflicts table (detailed conflict records)
CREATE TABLE IF NOT EXISTS vexlens.consensus_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
projection_id UUID NOT NULL REFERENCES vexlens.consensus_projections(id) ON DELETE CASCADE,
issuer1 TEXT NOT NULL,
issuer2 TEXT NOT NULL,
status1 TEXT NOT NULL,
status2 TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL')),
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_consensus_conflicts_projection
ON vexlens.consensus_conflicts (projection_id);
CREATE INDEX IF NOT EXISTS idx_consensus_conflicts_severity
ON vexlens.consensus_conflicts (severity);
-- Comments for documentation
COMMENT ON TABLE vexlens.consensus_projections IS 'VEX consensus computation results with history tracking';
COMMENT ON COLUMN vexlens.consensus_projections.confidence_score IS 'Aggregated confidence score from 0.0 to 1.0';
COMMENT ON COLUMN vexlens.consensus_projections.outcome IS 'Consensus outcome: unanimous (all agree), majority (>50% agree), conflict (tie), single_source, none';
COMMENT ON COLUMN vexlens.consensus_projections.status_changed IS 'TRUE if this projection changed status from previous';
COMMENT ON COLUMN vexlens.consensus_projections.input_hash IS 'SHA256 hash of sorted inputs for replay verification';
COMMENT ON TABLE vexlens.consensus_inputs IS 'VEX statements that contributed to a consensus projection';
COMMENT ON TABLE vexlens.consensus_conflicts IS 'Conflicts detected during consensus computation';

View File

@@ -0,0 +1,582 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Storage;
namespace StellaOps.VexLens.Persistence.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-021)
/// </summary>
public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
{
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Persistence.PostgresConsensusProjectionStore");
private readonly NpgsqlDataSource _dataSource;
private readonly IConsensusEventEmitter? _eventEmitter;
private readonly ILogger<PostgresConsensusProjectionStore> _logger;
private readonly TimeProvider _timeProvider;
public PostgresConsensusProjectionStore(
NpgsqlDataSource dataSource,
ILogger<PostgresConsensusProjectionStore> logger,
TimeProvider? timeProvider = null,
IConsensusEventEmitter? eventEmitter = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_eventEmitter = eventEmitter;
}
/// <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 = Guid.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 &&
!string.Equals(previous.Status.ToString(), result.ConsensusStatus.ToString(), StringComparison.OrdinalIgnoreCase);
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
// Insert the projection
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 configured
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 projections
var projections = new List<ConsensusProjection>();
await using var cmd = new NpgsqlCommand(sql, connection);
foreach (var (name, value) in parameters)
{
cmd.Parameters.AddWithValue(name, value);
}
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 ?? 100);
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"));
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(
tenantId is null ? PurgeSql : PurgeByTenantSql,
connection);
cmd.Parameters.AddWithValue("older_than", olderThan);
if (tenantId is not null)
{
cmd.Parameters.AddWithValue("tenant_id", tenantId);
}
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
return deleted;
}
#region SQL Queries
private const string InsertProjectionSql = """
INSERT INTO vexlens.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 vexlens.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 vexlens.consensus_projections
WHERE vulnerability_id = @vulnerability_id
AND product_key = @product_key
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
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 vexlens.consensus_projections
WHERE vulnerability_id = @vulnerability_id
AND product_key = @product_key
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
ORDER BY computed_at DESC
LIMIT @limit
""";
private const string PurgeSql = """
DELETE FROM vexlens.consensus_projections
WHERE computed_at < @older_than
""";
private const string PurgeByTenantSql = """
DELETE FROM vexlens.consensus_projections
WHERE computed_at < @older_than AND tenant_id = @tenant_id
""";
#endregion
#region Helpers
private static ConsensusProjection MapProjection(NpgsqlDataReader 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",
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
private static VexStatus ParseStatus(string status) => status switch
{
"not_affected" => VexStatus.NotAffected,
"affected" => VexStatus.Affected,
"fixed" => VexStatus.Fixed,
"under_investigation" => VexStatus.UnderInvestigation,
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
private static string MapJustification(VexJustification justification) => justification switch
{
VexJustification.ComponentNotPresent => "component_not_present",
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
_ => throw new ArgumentOutOfRangeException(nameof(justification))
};
private static VexJustification ParseJustification(string justification) => justification switch
{
"component_not_present" => VexJustification.ComponentNotPresent,
"vulnerable_code_not_present" => VexJustification.VulnerableCodeNotPresent,
"vulnerable_code_not_in_execute_path" => VexJustification.VulnerableCodeNotInExecutePath,
"vulnerable_code_cannot_be_controlled_by_adversary" => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
_ => throw new ArgumentOutOfRangeException(nameof(justification))
};
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",
_ => throw new ArgumentOutOfRangeException(nameof(outcome))
};
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,
_ => throw new ArgumentOutOfRangeException(nameof(outcome))
};
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
{
var conditions = new List<string>();
var parameters = new List<(string name, object value)>();
if (query.TenantId is not null)
{
conditions.Add("tenant_id = @tenant_id");
parameters.Add(("tenant_id", query.TenantId));
}
if (query.VulnerabilityId is not null)
{
conditions.Add("vulnerability_id = @vulnerability_id");
parameters.Add(("vulnerability_id", query.VulnerabilityId));
}
if (query.ProductKey is not null)
{
conditions.Add("product_key = @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 sortColumn = 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 sortDirection = 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 vexlens.consensus_projections
{whereClause}
ORDER BY {sortColumn} {sortDirection}
LIMIT {query.Limit} OFFSET {query.Offset}
""";
var countSql = $"SELECT COUNT(*) FROM vexlens.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();
// Always emit computed event
await _eventEmitter.EmitConsensusComputedAsync(
new ConsensusComputedEvent(
EventId: Guid.NewGuid().ToString(),
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),
cancellationToken);
// Emit status changed if applicable
if (projection.StatusChanged && previous is not null)
{
await _eventEmitter.EmitStatusChangedAsync(
new ConsensusStatusChangedEvent(
EventId: Guid.NewGuid().ToString(),
ProjectionId: projection.ProjectionId,
VulnerabilityId: projection.VulnerabilityId,
ProductKey: projection.ProductKey,
TenantId: projection.TenantId,
PreviousStatus: previous.Status,
NewStatus: projection.Status,
ChangeReason: null,
ComputedAt: projection.ComputedAt,
EmittedAt: now),
cancellationToken);
}
// Emit conflict detected if there are conflicts
if (projection.ConflictCount > 0)
{
await _eventEmitter.EmitConflictDetectedAsync(
new ConsensusConflictDetectedEvent(
EventId: Guid.NewGuid().ToString(),
ProjectionId: projection.ProjectionId,
VulnerabilityId: projection.VulnerabilityId,
ProductKey: projection.ProductKey,
TenantId: projection.TenantId,
ConflictCount: projection.ConflictCount,
MaxSeverity: ConflictSeverity.Medium, // Default, would be computed from actual conflicts
Conflicts: [],
DetectedAt: projection.ComputedAt,
EmittedAt: now),
cancellationToken);
}
}
#endregion
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-020) -->
<RootNamespace>StellaOps.VexLens.Persistence</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,291 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens", "StellaOps.VexLens", "{4D88C818-3BB3-BC3B-D3D1-F21617D09654}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens.Persistence", "StellaOps.VexLens.Persistence", "{A08207CC-507B-A415-392F-6FF2DB6342CA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens.Core", "StellaOps.VexLens.Core", "{B0727DBA-83FD-619F-5D33-3F4652753F2E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{CC393719-C997-B814-42B3-0BA762B74BF7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.VexLens.Core.Tests", "StellaOps.VexLens.Core.Tests", "{C314BF02-26DE-60BD-BAA5-AA4C52869724}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aoc", "Aoc", "{03DFF14F-7321-1784-D4C7-4E99D4120F48}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BDD326D6-7616-84F0-B914-74743BFBA520}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Aoc", "StellaOps.Aoc", "{EC506DBE-AB6D-492E-786E-8B176021BF2E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Attestor", "Attestor", "{5AC09D9A-F2A5-9CFA-B3C5-8D25F257651C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor", "StellaOps.Attestor", "{33B1AE27-692A-1778-48C1-CCEC2B9BC78F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope", "{018E0E11-1CCE-A2BE-641D-21EE14D2E90D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Core", "StellaOps.Attestor.Core", "{5F27FB4E-CF09-3A6B-F5B4-BF5A709FA609}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB67BDB9-D701-3AC9-9CDF-ECCDCCD8DB6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain", "StellaOps.Attestor.ProofChain", "{45F7FA87-7451-6970-7F6E-F8BAE45E081B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Concelier", "Concelier", "{157C3671-CA0B-69FA-A7C9-74A1FDA97B99}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{F39E09D6-BF93-B64A-CFE7-2BA92815C0FE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.RawModels", "StellaOps.Concelier.RawModels", "{1DCF4EBB-DBC4-752C-13D4-D1EECE4E8907}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceIntel", "StellaOps.Concelier.SourceIntel", "{F2B58F4E-6F28-A25F-5BFB-CDEBAD6B9A3E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Excititor", "Excititor", "{7D49FA52-6EA1-EAC8-4C5A-AC07188D6C57}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{C9CF27FC-12DB-954F-863C-576BA8E309A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core", "StellaOps.Excititor.Core", "{6DCAF6F3-717F-27A9-D96C-F2BFA5550347}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feedser", "Feedser", "{C4A90603-BE42-0044-CAB4-3EB910AD51A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.BinaryAnalysis", "StellaOps.Feedser.BinaryAnalysis", "{054761F9-16D3-B2F8-6F4D-EFC2248805CD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core", "{B54CE64C-4167-1DD1-B7D6-2FD7A5AEF715}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Policy", "Policy", "{8E6B774C-CC4E-CE7C-AD4B-8AF7C92889A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy.RiskProfile", "StellaOps.Policy.RiskProfile", "{BC12ED55-6015-7C8B-8384-B39CE93C76D6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{FF70543D-AFF9-1D38-4950-4F8EE18D60BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy", "StellaOps.Policy", "{831265B0-8896-9C95-3488-E12FD9F6DC53}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Provenance", "Provenance", "{316BBD0A-04D2-85C9-52EA-7993CC6C8930}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Provenance.Attestation", "StellaOps.Provenance.Attestation", "{9D6AB85A-85EA-D85A-5566-A121D34016E6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Signer", "Signer", "{3247EE0D-B3E9-9C11-B0AE-FE719410390B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer", "StellaOps.Signer", "{CD7C09DA-FEC8-2CC5-D00C-E525638DFF4A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signer.Core", "StellaOps.Signer.Core", "{79B10804-91E9-972E-1913-EE0F0B11663E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Kms", "StellaOps.Cryptography.Kms", "{5AC9EE40-1881-5F8A-46A2-2C303950D3C8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Core", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj", "{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Kms", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj", "{F3A27846-6DE0-3448-222C-25A273E86B2E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance.Attestation", "E:\dev\git.stella-ops.org\src\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj", "{A78EBC0F-C62C-8F56-95C0-330E376242A2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signer.Core", "E:\dev\git.stella-ops.org\src\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj", "{0AF13355-173C-3128-5AFC-D32E540DA3EF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens", "StellaOps.VexLens\StellaOps.VexLens.csproj", "{33565FF8-EBD5-53F8-B786-95111ACDF65F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Core", "StellaOps.VexLens\StellaOps.VexLens.Core\StellaOps.VexLens.Core.csproj", "{12F72803-F28C-8F72-1BA0-3911231DD8AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Core.Tests", "StellaOps.VexLens\__Tests\StellaOps.VexLens.Core.Tests\StellaOps.VexLens.Core.Tests.csproj", "{3A4678E5-957B-1E59-9A19-50C8A60F53DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.VexLens.Persistence", "StellaOps.VexLens.Persistence\StellaOps.VexLens.Persistence.csproj", "{0F9CBD78-C279-951B-A38F-A0AA57B62517}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Debug|Any CPU.Build.0 = Debug|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.ActiveCfg = Release|Any CPU
{776E2142-804F-03B9-C804-D061D64C6092}.Release|Any CPU.Build.0 = Release|Any CPU
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B4DF41E-C8CC-2606-FA2D-967118BD3C59}.Release|Any CPU.Build.0 = Release|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}.Release|Any CPU.Build.0 = Release|Any CPU
{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}.Debug|Any CPU.ActiveCfg = Debug|Any CPU

View File

@@ -1,6 +1,9 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.VexLens.Api;
using StellaOps.VexLens.Caching;
using StellaOps.VexLens.Consensus;
@@ -169,9 +172,16 @@ public static class VexLensServiceCollectionExtensions
IServiceCollection services,
VexLensStorageOptions options)
{
var driver = (options.Driver ?? "memory").Trim();
var driver = (options.Driver ?? "memory").Trim().ToLowerInvariant();
switch (driver.ToLowerInvariant())
// Feature flag: UsePostgres upgrades memory driver to dual-write automatically
// Sprint: LIN-BE-022
if (options.UsePostgres && driver == "memory")
{
driver = "dual-write";
}
switch (driver)
{
case "memory":
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
@@ -181,9 +191,105 @@ public static class VexLensServiceCollectionExtensions
});
break;
case "postgres":
// PostgreSQL-only mode (post-migration)
RegisterPostgresStore(services, options);
break;
case "dual-write":
// Dual-write mode for migration from memory to postgres
RegisterDualWriteStore(services, options);
break;
default:
throw new InvalidOperationException(
$"Unsupported VexLens storage driver: '{options.Driver}'. Supported drivers: memory.");
$"Unsupported VexLens storage driver: '{options.Driver}'. Supported drivers: memory, postgres, dual-write.");
}
}
/// <summary>
/// Registers PostgreSQL-only consensus projection store.
/// </summary>
private static void RegisterPostgresStore(
IServiceCollection services,
VexLensStorageOptions options)
{
if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
throw new InvalidOperationException(
"VexLens:Storage:ConnectionString is required when using postgres driver.");
}
// Register NpgsqlDataSource for the connection
services.TryAddSingleton(sp =>
{
var connStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
{
CommandTimeout = options.CommandTimeoutSeconds
};
var builder = new NpgsqlDataSourceBuilder(connStringBuilder.ConnectionString);
return builder.Build();
});
// Register the PostgreSQL store
// Note: The actual PostgresConsensusProjectionStore is in StellaOps.VexLens.Persistence
// This registers a factory that creates it
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
{
var dataSource = sp.GetRequiredService<NpgsqlDataSource>();
var logger = sp.GetRequiredService<ILogger<PostgresConsensusProjectionStoreProxy>>();
var emitter = sp.GetService<IConsensusEventEmitter>();
return new PostgresConsensusProjectionStoreProxy(dataSource, logger, emitter, options);
});
}
/// <summary>
/// Registers dual-write consensus projection store for migration.
/// Sprint: LIN-BE-022
/// </summary>
private static void RegisterDualWriteStore(
IServiceCollection services,
VexLensStorageOptions options)
{
if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
throw new InvalidOperationException(
"VexLens:Storage:ConnectionString is required when using dual-write driver.");
}
// Register NpgsqlDataSource
services.TryAddSingleton(sp =>
{
var connStringBuilder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
{
CommandTimeout = options.CommandTimeoutSeconds
};
var builder = new NpgsqlDataSourceBuilder(connStringBuilder.ConnectionString);
return builder.Build();
});
// Register options for DualWriteStore
services.TryAddSingleton(Microsoft.Extensions.Options.Options.Create(options));
// Register dual-write store
services.TryAddSingleton<IConsensusProjectionStore>(sp =>
{
var emitter = sp.GetRequiredService<IConsensusEventEmitter>();
var memoryStore = new InMemoryConsensusProjectionStore(emitter);
var dataSource = sp.GetRequiredService<NpgsqlDataSource>();
var postgresLogger = sp.GetRequiredService<ILogger<PostgresConsensusProjectionStoreProxy>>();
var postgresStore = new PostgresConsensusProjectionStoreProxy(dataSource, postgresLogger, emitter, options);
var dualWriteOptions = sp.GetRequiredService<IOptions<VexLensStorageOptions>>();
var dualWriteLogger = sp.GetRequiredService<ILogger<DualWriteConsensusProjectionStore>>();
return new DualWriteConsensusProjectionStore(
memoryStore,
postgresStore,
dualWriteOptions,
dualWriteLogger);
});
}
}

View File

@@ -48,10 +48,19 @@ public sealed class VexLensStorageOptions
{
/// <summary>
/// Storage driver for consensus projections.
/// Supported drivers: "memory" (default) until a persistent provider is introduced.
/// Supported drivers: "memory" (default), "postgres", "dual-write".
/// Use "dual-write" for migration from memory to postgres.
/// </summary>
public string Driver { get; set; } = "memory";
/// <summary>
/// Enable PostgreSQL storage for consensus projections.
/// When true and Driver is "memory", automatically upgrades to "dual-write" mode.
/// Feature flag: VexLens:Storage:UsePostgres
/// Sprint: LIN-BE-022
/// </summary>
public bool UsePostgres { get; set; } = false;
/// <summary>
/// Optional connection string for persistent storage drivers (unused for in-memory).
/// </summary>
@@ -76,6 +85,18 @@ public sealed class VexLensStorageOptions
/// Command timeout in seconds.
/// </summary>
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// During dual-write mode, which store to use for reads.
/// Options: "memory" (default during migration), "postgres".
/// Once migration is verified, switch to "postgres" then to postgres-only driver.
/// </summary>
public string DualWriteReadFrom { get; set; } = "memory";
/// <summary>
/// Whether to log dual-write discrepancies for migration validation.
/// </summary>
public bool LogDualWriteDiscrepancies { get; set; } = true;
}
/// <summary>

View File

@@ -0,0 +1,318 @@
// -----------------------------------------------------------------------------
// VexDeltaComputeService.cs
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-009)
// Updated: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-026)
// Task: Compute and store VEX deltas on consensus status change + attestations
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Delta;
using StellaOps.Excititor.Persistence.Repositories;
using SignerPredicates = StellaOps.Signer.Core.Predicates;
namespace StellaOps.VexLens.Services;
/// <summary>
/// Service that computes and stores VEX deltas when consensus status changes.
/// Called by the consensus projection store when StatusChanged=true.
/// </summary>
public interface IVexDeltaComputeService
{
/// <summary>
/// Computes and stores a VEX delta for a consensus status change.
/// </summary>
Task ComputeAndStoreAsync(VexStatusChangeContext context, CancellationToken ct = default);
}
/// <summary>
/// Context for a VEX status change event.
/// </summary>
public sealed record VexStatusChangeContext
{
/// <summary>
/// Projection ID that changed.
/// </summary>
public required Guid ProjectionId { get; init; }
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Product key (artifact reference).
/// </summary>
public required string ProductKey { get; init; }
/// <summary>
/// Artifact digest.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Previous artifact digest (parent in lineage).
/// </summary>
public string? PreviousArtifactDigest { get; init; }
/// <summary>
/// Previous VEX status.
/// </summary>
public required string PreviousStatus { get; init; }
/// <summary>
/// New VEX status.
/// </summary>
public required string NewStatus { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Justification for the status.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Change reason summary.
/// </summary>
public string? ChangeReason { get; init; }
/// <summary>
/// Confidence score.
/// </summary>
public double ConfidenceScore { get; init; }
/// <summary>
/// When the change was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Implementation of <see cref="IVexDeltaComputeService"/>.
/// </summary>
public sealed class VexDeltaComputeService : IVexDeltaComputeService
{
private readonly IVexDeltaRepository _deltaRepository;
private readonly IDeltaAttestationService? _attestationService;
private readonly ILogger<VexDeltaComputeService> _logger;
public VexDeltaComputeService(
IVexDeltaRepository deltaRepository,
ILogger<VexDeltaComputeService> logger,
IDeltaAttestationService? attestationService = null)
{
_deltaRepository = deltaRepository ?? throw new ArgumentNullException(nameof(deltaRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_attestationService = attestationService;
}
/// <inheritdoc/>
public async Task ComputeAndStoreAsync(VexStatusChangeContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
// Skip if no previous version to compare against
if (string.IsNullOrWhiteSpace(context.PreviousArtifactDigest))
{
_logger.LogDebug(
"Skipping VEX delta for {Cve}: no previous artifact digest for {ArtifactDigest}",
context.VulnerabilityId,
context.ArtifactDigest);
return;
}
// Skip if status hasn't actually changed
if (string.Equals(context.PreviousStatus, context.NewStatus, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug(
"Skipping VEX delta for {Cve}: status unchanged ({Status})",
context.VulnerabilityId,
context.NewStatus);
return;
}
// Build rationale
var rationale = new VexDeltaRationale
{
Reason = context.ChangeReason ?? $"Status changed from {context.PreviousStatus} to {context.NewStatus}",
Source = "vexlens-consensus",
Confidence = context.ConfidenceScore,
Notes = context.Justification
};
// Compute replay hash for reproducibility
var replayHash = ComputeReplayHash(context);
var delta = new VexDelta
{
FromArtifactDigest = context.PreviousArtifactDigest,
ToArtifactDigest = context.ArtifactDigest,
Cve = context.VulnerabilityId,
FromStatus = context.PreviousStatus,
ToStatus = context.NewStatus,
Rationale = rationale,
ReplayHash = replayHash,
TenantId = context.TenantId,
CreatedAt = context.ComputedAt
};
var success = await _deltaRepository.AddAsync(delta, ct).ConfigureAwait(false);
if (success)
{
_logger.LogInformation(
"Stored VEX delta for {Cve}: {PreviousStatus} -> {NewStatus} ({FromDigest} -> {ToDigest})",
context.VulnerabilityId,
context.PreviousStatus,
context.NewStatus,
TruncateDigest(context.PreviousArtifactDigest),
TruncateDigest(context.ArtifactDigest));
// Create signed attestation for the delta
await CreateDeltaAttestationAsync(delta, context, ct).ConfigureAwait(false);
}
else
{
_logger.LogWarning(
"Failed to store VEX delta for {Cve}: {PreviousStatus} -> {NewStatus}",
context.VulnerabilityId,
context.PreviousStatus,
context.NewStatus);
}
}
/// <summary>
/// Creates a signed attestation for a VEX delta.
/// </summary>
private async Task CreateDeltaAttestationAsync(VexDelta delta, VexStatusChangeContext context, CancellationToken ct)
{
if (_attestationService is null)
{
_logger.LogDebug("Delta attestation service not available; skipping attestation for delta {Id}", delta.Id);
return;
}
try
{
// Determine if this is a status upgrade (e.g., affected -> not_affected)
var isResolved = string.Equals(delta.ToStatus, "not_affected", StringComparison.OrdinalIgnoreCase) ||
string.Equals(delta.ToStatus, "fixed", StringComparison.OrdinalIgnoreCase);
// Build the VEX delta predicate using the Signer.Core model
var predicate = new SignerPredicates.VexDeltaPredicate
{
FromDigest = delta.FromArtifactDigest,
ToDigest = delta.ToArtifactDigest,
ComputedAt = delta.CreatedAt,
TenantId = context.TenantId,
StatusChanges = ImmutableArray.Create(new SignerPredicates.VexStatusChange
{
Cve = delta.Cve,
FromStatus = delta.FromStatus,
ToStatus = delta.ToStatus,
Reason = delta.Rationale?.Reason
}),
ResolvedVulnerabilities = isResolved
? ImmutableArray.Create(new SignerPredicates.VexVulnerabilityEntry
{
Cve = delta.Cve,
Status = delta.ToStatus
})
: ImmutableArray<SignerPredicates.VexVulnerabilityEntry>.Empty,
Summary = new SignerPredicates.VexDeltaSummary
{
StatusChangeCount = 1,
NewVulnCount = 0,
ResolvedVulnCount = isResolved ? 1 : 0,
CriticalNew = 0,
HighNew = 0
}
};
var request = new VexDeltaAttestationRequest
{
FromDigest = delta.FromArtifactDigest,
ToDigest = delta.ToArtifactDigest,
TenantId = context.TenantId,
Delta = predicate,
UseKeyless = true,
UseTransparencyLog = true
};
var result = await _attestationService.CreateVexDeltaAttestationAsync(request, ct).ConfigureAwait(false);
if (result.Success && !string.IsNullOrEmpty(result.AttestationDigest))
{
// Update the delta with the attestation digest
var updated = await _deltaRepository.UpdateAttestationDigestAsync(delta.Id, result.AttestationDigest, ct)
.ConfigureAwait(false);
if (updated)
{
_logger.LogInformation(
"Created attestation for VEX delta {DeltaId}: digest={AttestationDigest}",
delta.Id,
TruncateDigest(result.AttestationDigest));
}
else
{
_logger.LogWarning(
"Failed to update attestation digest for delta {DeltaId}",
delta.Id);
}
}
else
{
_logger.LogWarning(
"Failed to create attestation for VEX delta {DeltaId}: {Error}",
delta.Id,
result.Error ?? "Unknown error");
}
}
catch (Exception ex)
{
// Log but don't fail the delta storage on attestation failure
_logger.LogError(ex, "Error creating attestation for VEX delta {DeltaId}", delta.Id);
}
}
/// <summary>
/// Computes a deterministic replay hash for the delta.
/// </summary>
private static string ComputeReplayHash(VexStatusChangeContext context)
{
// Create a deterministic input string
var input = string.Join("|",
context.VulnerabilityId,
context.PreviousArtifactDigest,
context.ArtifactDigest,
context.PreviousStatus,
context.NewStatus,
context.ComputedAt.ToUniversalTime().ToString("O"));
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string TruncateDigest(string digest)
{
if (string.IsNullOrEmpty(digest))
{
return digest;
}
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
}

View File

@@ -10,8 +10,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,11 +10,22 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<!-- LIN-BE-022: PostgreSQL support for consensus projection store -->
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<!-- LIN-BE-026: Delta attestation support -->
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<!-- VEX delta repository and models from Excititor -->
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<!-- Exclude legacy folders with external dependencies -->

View File

@@ -0,0 +1,306 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Options;
namespace StellaOps.VexLens.Storage;
/// <summary>
/// Dual-write implementation of <see cref="IConsensusProjectionStore"/> for migration.
/// Writes to both in-memory and PostgreSQL stores, reads from configurable primary.
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-022)
/// </summary>
public sealed class DualWriteConsensusProjectionStore : IConsensusProjectionStore
{
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Storage.DualWriteConsensusProjectionStore");
private readonly IConsensusProjectionStore _memoryStore;
private readonly IConsensusProjectionStore _postgresStore;
private readonly VexLensStorageOptions _options;
private readonly ILogger<DualWriteConsensusProjectionStore> _logger;
private readonly bool _readFromPostgres;
private readonly bool _logDiscrepancies;
/// <summary>
/// Creates a dual-write store for migration from in-memory to PostgreSQL.
/// </summary>
/// <param name="memoryStore">The in-memory store (primary during migration).</param>
/// <param name="postgresStore">The PostgreSQL store (target for migration).</param>
/// <param name="options">Storage options including read preference.</param>
/// <param name="logger">Logger for discrepancy reporting.</param>
public DualWriteConsensusProjectionStore(
IConsensusProjectionStore memoryStore,
IConsensusProjectionStore postgresStore,
IOptions<VexLensStorageOptions> options,
ILogger<DualWriteConsensusProjectionStore> logger)
{
_memoryStore = memoryStore ?? throw new ArgumentNullException(nameof(memoryStore));
_postgresStore = postgresStore ?? throw new ArgumentNullException(nameof(postgresStore));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_readFromPostgres = string.Equals(_options.DualWriteReadFrom, "postgres", StringComparison.OrdinalIgnoreCase);
_logDiscrepancies = _options.LogDualWriteDiscrepancies;
_logger.LogInformation(
"DualWriteConsensusProjectionStore initialized. ReadFrom={ReadFrom}, LogDiscrepancies={LogDiscrepancies}",
_options.DualWriteReadFrom, _logDiscrepancies);
}
/// <summary>
/// Gets the primary store for reads based on configuration.
/// </summary>
private IConsensusProjectionStore PrimaryStore => _readFromPostgres ? _postgresStore : _memoryStore;
/// <summary>
/// Gets the secondary store for writes (opposite of primary).
/// </summary>
private IConsensusProjectionStore SecondaryStore => _readFromPostgres ? _memoryStore : _postgresStore;
/// <inheritdoc />
public async Task<ConsensusProjection> StoreAsync(
VexConsensusResult result,
StoreProjectionOptions options,
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("StoreAsync.DualWrite");
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
activity?.SetTag("productKey", result.ProductKey);
// Write to primary first
var primaryResult = await PrimaryStore.StoreAsync(result, options, cancellationToken);
activity?.SetTag("primaryProjectionId", primaryResult.ProjectionId);
// Write to secondary (fire-and-forget with error logging)
try
{
var secondaryResult = await SecondaryStore.StoreAsync(result, options, cancellationToken);
activity?.SetTag("secondaryProjectionId", secondaryResult.ProjectionId);
if (_logDiscrepancies)
{
await ValidateStoreResultsAsync(primaryResult, secondaryResult, activity);
}
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Dual-write to secondary store failed for {VulnerabilityId}/{ProductKey}. Primary write succeeded.",
result.VulnerabilityId, result.ProductKey);
activity?.SetTag("secondaryWriteFailed", true);
}
return primaryResult;
}
/// <inheritdoc />
public async Task<ConsensusProjection?> GetAsync(
string projectionId,
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("GetAsync.DualWrite");
activity?.SetTag("projectionId", projectionId);
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
var result = await PrimaryStore.GetAsync(projectionId, cancellationToken);
if (_logDiscrepancies && result is not null)
{
_ = Task.Run(async () =>
{
try
{
var secondaryResult = await SecondaryStore.GetAsync(projectionId, CancellationToken.None);
if (secondaryResult is null)
{
_logger.LogWarning(
"Dual-write discrepancy: Projection {ProjectionId} found in primary but not in secondary",
projectionId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Secondary lookup failed during discrepancy check for {ProjectionId}", projectionId);
}
}, cancellationToken);
}
return result;
}
/// <inheritdoc />
public async Task<ConsensusProjection?> GetLatestAsync(
string vulnerabilityId,
string productKey,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("GetLatestAsync.DualWrite");
activity?.SetTag("vulnerabilityId", vulnerabilityId);
activity?.SetTag("productKey", productKey);
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
var result = await PrimaryStore.GetLatestAsync(vulnerabilityId, productKey, tenantId, cancellationToken);
if (_logDiscrepancies)
{
_ = Task.Run(async () =>
{
try
{
var secondaryResult = await SecondaryStore.GetLatestAsync(
vulnerabilityId, productKey, tenantId, CancellationToken.None);
ValidateProjectionConsistency(result, secondaryResult, vulnerabilityId, productKey);
}
catch (Exception ex)
{
_logger.LogDebug(
ex,
"Secondary lookup failed during discrepancy check for {VulnerabilityId}/{ProductKey}",
vulnerabilityId, productKey);
}
}, cancellationToken);
}
return result;
}
/// <inheritdoc />
public async Task<ProjectionListResult> ListAsync(
ProjectionQuery query,
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("ListAsync.DualWrite");
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
// List operations only read from primary for consistency
return await PrimaryStore.ListAsync(query, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
string vulnerabilityId,
string productKey,
string? tenantId = null,
int? limit = null,
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("GetHistoryAsync.DualWrite");
activity?.SetTag("vulnerabilityId", vulnerabilityId);
activity?.SetTag("productKey", productKey);
activity?.SetTag("readFrom", _readFromPostgres ? "postgres" : "memory");
return await PrimaryStore.GetHistoryAsync(vulnerabilityId, productKey, tenantId, limit, cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeAsync(
DateTimeOffset olderThan,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("PurgeAsync.DualWrite");
activity?.SetTag("olderThan", olderThan.ToString("O"));
// Purge from both stores
var primaryCount = await PrimaryStore.PurgeAsync(olderThan, tenantId, cancellationToken);
activity?.SetTag("primaryPurgedCount", primaryCount);
try
{
var secondaryCount = await SecondaryStore.PurgeAsync(olderThan, tenantId, cancellationToken);
activity?.SetTag("secondaryPurgedCount", secondaryCount);
if (_logDiscrepancies && primaryCount != secondaryCount)
{
_logger.LogWarning(
"Dual-write discrepancy: Purge count differs. Primary={PrimaryCount}, Secondary={SecondaryCount}",
primaryCount, secondaryCount);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Dual-write purge failed for secondary store");
}
return primaryCount;
}
private async Task ValidateStoreResultsAsync(
ConsensusProjection primary,
ConsensusProjection secondary,
Activity? activity)
{
// Basic validation - the key fields should match
if (primary.VulnerabilityId != secondary.VulnerabilityId ||
primary.ProductKey != secondary.ProductKey ||
primary.Status != secondary.Status ||
Math.Abs(primary.ConfidenceScore - secondary.ConfidenceScore) > 0.001)
{
_logger.LogWarning(
"Dual-write discrepancy detected for {VulnerabilityId}/{ProductKey}. " +
"Primary: Status={PrimaryStatus}, Confidence={PrimaryConfidence}. " +
"Secondary: Status={SecondaryStatus}, Confidence={SecondaryConfidence}",
primary.VulnerabilityId,
primary.ProductKey,
primary.Status,
primary.ConfidenceScore,
secondary.Status,
secondary.ConfidenceScore);
activity?.SetTag("discrepancyDetected", true);
}
await Task.CompletedTask;
}
private void ValidateProjectionConsistency(
ConsensusProjection? primary,
ConsensusProjection? secondary,
string vulnerabilityId,
string productKey)
{
if (primary is null && secondary is null)
{
return;
}
if (primary is null && secondary is not null)
{
_logger.LogWarning(
"Dual-write discrepancy: Projection for {VulnerabilityId}/{ProductKey} exists in secondary but not primary",
vulnerabilityId, productKey);
return;
}
if (primary is not null && secondary is null)
{
_logger.LogWarning(
"Dual-write discrepancy: Projection for {VulnerabilityId}/{ProductKey} exists in primary but not secondary",
vulnerabilityId, productKey);
return;
}
// Both exist - compare key fields
if (primary!.Status != secondary!.Status)
{
_logger.LogWarning(
"Dual-write discrepancy: Status mismatch for {VulnerabilityId}/{ProductKey}. Primary={PrimaryStatus}, Secondary={SecondaryStatus}",
vulnerabilityId, productKey, primary.Status, secondary.Status);
}
if (Math.Abs(primary.ConfidenceScore - secondary.ConfidenceScore) > 0.001)
{
_logger.LogWarning(
"Dual-write discrepancy: Confidence mismatch for {VulnerabilityId}/{ProductKey}. Primary={PrimaryConfidence}, Secondary={SecondaryConfidence}",
vulnerabilityId, productKey, primary.ConfidenceScore, secondary.ConfidenceScore);
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Services;
namespace StellaOps.VexLens.Storage;
@@ -13,10 +14,15 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
private readonly ConcurrentDictionary<string, ConsensusProjection> _projectionsById = new();
private readonly ConcurrentDictionary<string, List<ConsensusProjection>> _projectionsByKey = new();
private readonly IConsensusEventEmitter? _eventEmitter;
// LIN-BE-009: Delta service for computing VEX deltas on status change
private readonly IVexDeltaComputeService? _deltaComputeService;
public InMemoryConsensusProjectionStore(IConsensusEventEmitter? eventEmitter = null)
public InMemoryConsensusProjectionStore(
IConsensusEventEmitter? eventEmitter = null,
IVexDeltaComputeService? deltaComputeService = null)
{
_eventEmitter = eventEmitter;
_deltaComputeService = deltaComputeService;
}
public async Task<ConsensusProjection> StoreAsync(
@@ -312,6 +318,28 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
ComputedAt: projection.ComputedAt,
EmittedAt: now),
cancellationToken);
// LIN-BE-009: Compute and store VEX delta on status change
if (_deltaComputeService != null)
{
await _deltaComputeService.ComputeAndStoreAsync(
new VexStatusChangeContext
{
ProjectionId = Guid.TryParse(projection.ProjectionId, out var pid) ? pid : Guid.NewGuid(),
VulnerabilityId = projection.VulnerabilityId,
ProductKey = projection.ProductKey,
ArtifactDigest = projection.ProductKey, // Use ProductKey as artifact identifier
PreviousArtifactDigest = previous.ProductKey, // Use ProductKey as artifact identifier
PreviousStatus = previous.Status.ToString(),
NewStatus = projection.Status.ToString(),
TenantId = projection.TenantId ?? "default",
Justification = projection.Justification?.ToString(),
ChangeReason = $"Consensus updated: {result.Rationale.Summary}",
ConfidenceScore = projection.ConfidenceScore,
ComputedAt = projection.ComputedAt
},
cancellationToken);
}
}
// Emit conflict event if conflicts detected

View File

@@ -0,0 +1,552 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Npgsql;
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;
public PostgresConsensusProjectionStoreProxy(
NpgsqlDataSource dataSource,
ILogger<PostgresConsensusProjectionStoreProxy> logger,
IConsensusEventEmitter? eventEmitter = null,
VexLensStorageOptions? options = null,
TimeProvider? timeProvider = 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;
}
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 = Guid.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"));
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(NpgsqlDataReader 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.GetDateTime(11),
StoredAt: reader.GetDateTime(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-{Guid.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-{Guid.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);
}
}
}

View File

@@ -5,19 +5,20 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.VexLens.Core/StellaOps.VexLens.Core.csproj" />
</ItemGroup>
</Project>
</Project>