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>